qi-coding-agent 0.1.0__py3-none-any.whl

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.
Files changed (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/extensions.py ADDED
@@ -0,0 +1,2267 @@
1
+ """扩展宿主:事件总线 + 扩展上下文 + 工具定义 + 传给 `register(api)` 的接口。
2
+
3
+ 对应 pi 的 `ExtensionAPI` / `ExtensionContext` / 事件订阅 / `registerTool`
4
+ (docs/extensions.md §4–§6)。
5
+
6
+ **公开面白名单**(§5.5 —— pi 的 `## Available Imports` 对应物)。承诺的只有本模块:
7
+
8
+ ExtensionBus / ExtensionApi / ExtensionContext / EmitResult
9
+ Tool / ToolError / ToolExecutor / ToolOutcome / register_tool
10
+
11
+ P-E2a 把 `Tool` 从 `registry.py` 搬了过来(那里只留 `ToolCatalog`):扩展要写工具
12
+ 就必然要这个类型,让它住在“注册表”里等于把内部结构当公开面。
13
+
14
+ ## 三条硬规则(§4,由本模块**实现**而不只是文档)
15
+
16
+ 1. **顺序 = 装载顺序**,且是**快照遍历** —— handler 里再 `on()` 不改变本次派发。
17
+ 否则"注册一个自己"会让派发越跑越多(中间件风格的自注册很常见)。
18
+ 2. **patch 链** —— handler 返回 `dict` 就浅合并进 payload,后面的 handler 看到前面改过的值;
19
+ payload 是**同一个 dict**,所以原地改字段同样对后续可见(对齐 pi 的 `event.input` 可原地改)。
20
+ 3. **失败隔离** —— 单个 handler 抛异常:记进 `EmitResult.errors` 并**继续链**,不中断会话。
21
+ 安全类事件(`tool_call` 这类"拦截器")可以给 `on_error_result` 做 fail-safe:
22
+ **闸门自己崩了就拦住,而不是放行**(放行等于"装了权限闸门反而更不安全")。
23
+
24
+ 不认识的返回值一律忽略(不报错):扩展可能比宿主新,而"插件发了东西但没人看见"
25
+ 比"多一个无害的返回值"难诊断得多。
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import contextlib
32
+ import inspect
33
+ import os
34
+ import time
35
+ from collections.abc import Awaitable, Callable, Sequence
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from .abort import AbortSignal
41
+ from .models import ToolOutcome
42
+
43
+ #: `handler(payload, ctx) -> dict | None | Awaitable[...]`
44
+ #: `payload` 是本次派发的那一个 dict(原地改 = 对后续 handler 可见)。
45
+ ExtensionHandler = Callable[[dict, "ExtensionContext"], Any | Awaitable[Any]]
46
+
47
+ #: 工具执行函数签名:`async def execute(args, ctx) -> str | ToolOutcome`
48
+ #: 返回 `str` 即视为成功;需要上报 status/exit_code 的工具返回 `ToolOutcome`。
49
+ ToolExecutor = Callable[[dict, Any], Awaitable[str | ToolOutcome]]
50
+
51
+ #: `deliver_as` 的两种写法归一:qi 的 snake_case 与 pi 的驼峰(值本身也是 pi 的约定)。
52
+ _DELIVER_AS = {
53
+ "steer": "steer",
54
+ "follow_up": "follow_up", "followUp": "follow_up", "followup": "follow_up",
55
+ "next_turn": "next_turn", "nextTurn": "next_turn", "nextturn": "next_turn",
56
+ }
57
+
58
+ #: 事件 payload 的**客户端兼容层**:同一个概念在 pi 那里是驼峰时,qi 额外镜一份。
59
+ #:
60
+ #: qi 自己的键名仍是 snake_case(见 docs/extensions.md §4),这里**不改名、只加一个别名** ——
61
+ #: 所以两边的读法都成立,而 qi 的既有消费者一字不改。
62
+ #: 只列两边**同名同义**的键;pi 的 payload 结构不同处(如 `turn_end` 带 `message`/`toolResults`,
63
+ #: qi 带 `text`/`tool_calls`)不在这里假造。
64
+ _PI_KEY_ALIASES = {
65
+ "tool_name": "toolName",
66
+ "tool_call_id": "toolCallId",
67
+ "tool_calls": "toolCalls",
68
+ "system_prompt": "systemPrompt",
69
+ "turn_index": "turnIndex",
70
+ "previous_level": "previousLevel",
71
+ "partial_result": "partialResult",
72
+ "is_error": "isError",
73
+ "exit_code": "exitCode",
74
+ "target_session_file": "targetSessionFile",
75
+ "entry_id": "entryId",
76
+ "target_id": "targetId",
77
+ "new_leaf_id": "newLeafId",
78
+ "exclude_from_context": "excludeFromContext",
79
+ "tokens_before": "tokensBefore",
80
+ "first_kept_entry_id": "firstKeptEntryId",
81
+ "error_message": "errorMessage",
82
+ }
83
+
84
+
85
+ def _alias_pi_keys(payload: dict) -> None:
86
+ """给 payload 补上 pi 的驼峰键名(已在则不动)。**原地改**。"""
87
+ for snake, camel in _PI_KEY_ALIASES.items():
88
+ if snake in payload and camel not in payload:
89
+ payload[camel] = payload[snake]
90
+
91
+
92
+ class ToolError(Exception):
93
+ """工具执行错误:以结果文本返回给模型,**不中断会话**(runner 会包成 error 结果)。"""
94
+
95
+
96
+ @dataclass
97
+ class Tool:
98
+ """一个可被模型调用的工具。
99
+
100
+ `description` 与 `prompt_snippet` 是**两个不同的面向**(pi 同款区分):
101
+
102
+ * `description` 进 tool schema —— 模型在“要不要调这个工具”时看的说明,可以长一些;
103
+ * `prompt_snippet` 进系统提示词「可用工具」那一行(省略则回落到 `description`)。
104
+
105
+ 合并写会让 schema 里塞进一整段散文 / 提示词里又缺一行摘要。`prompt_guidelines`
106
+ 是该工具被启用时才追加的指南 bullet(pi 的 `promptGuidelines`),**必须自带工具名**:
107
+ 指南是平铺追加的,写“用这个工具…”模型分不清“这个”指谁。
108
+ """
109
+
110
+ name: str
111
+ description: str
112
+ parameters: dict # JSON Schema
113
+ execute: ToolExecutor # async def execute(args, ctx) -> str | ToolOutcome
114
+ keywords: list[str] = field(default_factory=list)
115
+ prompt_snippet: str = ""
116
+ prompt_guidelines: list[str] = field(default_factory=list)
117
+ #: 谁装的(由 `register_tool` 盖):`{source, path, scope, origin}`。
118
+ #: `getAllTools()` 按它过滤/诊断 —— 扩展工具与内置工具靠这个区分。
119
+ source_info: dict | None = None
120
+ #: pi 的 `ToolDefinition.label` —— 界面上显示的名字(TUI 工具卡片标题)。
121
+ #: 空 = 回落到 `name`(不是所有工具都需要一个好看的名字)。
122
+ label: str = ""
123
+ #: pi 的 `prepareArguments`:拿到**原始**工具调用参数后、执行前的整理钩子。
124
+ #: 返回的 dict 才是真正执行用的那一份(也进 `tool_call` 事件的 `input`)。
125
+ #: qi **不做** schema 校验,所以它的位置就是“执行前最后一次整理”。
126
+ prepare_arguments: Callable[[Any], Any] | None = None
127
+ #: pi 的 `renderCall` / `renderResult` —— 扩展自己画工具卡片(TUI-only,见 §3.5)。
128
+ #: 签名 `(args|result, ctx) -> 组件`;不认识的返回值由前端退回默认渲染。
129
+ render_call: Callable[..., Any] | None = None
130
+ render_result: Callable[..., Any] | None = None
131
+ #: pi 的 `renderShell`:`"self"` = 扩展的组件**自己画框**(qi 不再套默认工具卡片底色/内边距),
132
+ #: 缺省 / `"default"` = 把组件放进默认外壳里。只对带 `render_call` 的工具才有意义。
133
+ render_shell: str = ""
134
+ #: pi 的 `constrainedSampling`:**收下但 qi 不支持**(逐工具受限采样要 provider 配合,
135
+ #: litellm 没有可移植的对应物)。注册时记一条 note 说明它被忽略 —— 不静默。
136
+ constrained_sampling: Any = None
137
+ #: pi 的 `executionMode`:`"parallel"` 时这个工具可以与同批其它 parallel 工具**并发执行**。
138
+ execution_mode: str = ""
139
+
140
+ @property
141
+ def prompt_line(self) -> str:
142
+ """系统提示词「可用工具」里那一行。回落规则只此一处。"""
143
+ return self.prompt_snippet or self.description
144
+
145
+ @property
146
+ def display_label(self) -> str:
147
+ """界面显示名(`label` 缺省回落到 `name`)。"""
148
+ return self.label or self.name
149
+
150
+ def to_llm_schema(self) -> dict:
151
+ return {
152
+ "type": "function",
153
+ "function": {
154
+ "name": self.name,
155
+ "description": self.description,
156
+ "parameters": self.parameters,
157
+ },
158
+ }
159
+
160
+
161
+ def register_tool(catalog: Any, tool: Tool, *, source: str = "", path: str = "",
162
+ scope: str = "", origin: str = "") -> None:
163
+ """把工具注册进 catalog,**并盖上来源**(`source_info`)。
164
+
165
+ 盖章只在这一处:让每个调用点自己拼一个 dict,迟早会漏字段,而 `source_info`
166
+ 是 `getAllTools()` 过滤和“这个工具到底谁装的”唯一依据。键名照 pi 的 `sourceInfo`。
167
+
168
+ 已带 `source_info` 的工具不覆盖(工具可以自己声明更精确的来源)。
169
+ """
170
+ if tool.source_info is None:
171
+ tool.source_info = {"source": source or "unknown", "path": path,
172
+ "scope": scope or "temporary", "origin": origin or "top-level"}
173
+ catalog.register(tool)
174
+
175
+
176
+ def _coerce_int(value: Any, default: int = 0) -> int:
177
+ """把配置里可能写成字符串/None 的数字收成 int(不因一个坏字段把 ctx 构造搞崩)。"""
178
+ try:
179
+ return int(value)
180
+ except (TypeError, ValueError):
181
+ return default
182
+
183
+
184
+ def _adapt_execute(fn: Any) -> ToolExecutor:
185
+ """pi 的 execute 是 `(toolCallId, params, signal, onUpdate, ctx)`,qi 是 `(args, ctx)`。
186
+
187
+ 按**形参个数**判:pi 形状的包一层(第 1 个参数给 `ctx.tool_call_id`,第 4 个给
188
+ `ctx.on_update`)。拿不到签名就原样用 —— 猜错比不猜更糟。
189
+ """
190
+ try:
191
+ params = inspect.signature(fn).parameters
192
+ except (TypeError, ValueError):
193
+ return fn
194
+ if len(params) < 4:
195
+ return fn
196
+
197
+ async def wrapper(args: dict, ctx: Any) -> Any:
198
+ result = fn(getattr(ctx, "tool_call_id", "") or "", args,
199
+ getattr(ctx, "abort", None), getattr(ctx, "on_update", None), ctx)
200
+ return await result if inspect.isawaitable(result) else result
201
+
202
+ return wrapper # type: ignore[return-value]
203
+
204
+
205
+ def _coerce_tool(tool: Any) -> Tool:
206
+ """把 pi 形状的 dict 工具定义收成 `Tool`(已经是 `Tool` 的原样返回)。"""
207
+ if isinstance(tool, Tool):
208
+ return tool
209
+ if not isinstance(tool, dict):
210
+ raise TypeError(f"register_tool 需要 Tool 或 dict,收到 {type(tool).__name__}")
211
+
212
+ def pick(*names: str) -> Any:
213
+ for key in names:
214
+ if tool.get(key) is not None:
215
+ return tool[key]
216
+ return None
217
+
218
+ name = pick("name")
219
+ execute = pick("execute")
220
+ if not name or not callable(execute):
221
+ raise TypeError("register_tool 的 dict 必须带 name 与 execute")
222
+ return Tool(
223
+ name=str(name),
224
+ description=str(pick("description") or ""),
225
+ parameters=pick("parameters") or {"type": "object", "properties": {}},
226
+ execute=_adapt_execute(execute),
227
+ prompt_snippet=str(pick("promptSnippet", "prompt_snippet") or ""),
228
+ prompt_guidelines=list(pick("promptGuidelines", "prompt_guidelines") or []),
229
+ label=str(pick("label") or ""),
230
+ prepare_arguments=pick("prepareArguments", "prepare_arguments"),
231
+ render_call=pick("renderCall", "render_call"),
232
+ render_result=pick("renderResult", "render_result"),
233
+ render_shell=str(pick("renderShell", "render_shell") or ""),
234
+ constrained_sampling=pick("constrainedSampling", "constrained_sampling"),
235
+ execution_mode=str(pick("executionMode", "execution_mode") or ""),
236
+ )
237
+
238
+
239
+ def tool_info(tool: Tool) -> dict:
240
+ """工具的公开元数据(`getAllTools()` 的元素)。
241
+
242
+ 字段用 snake_case(qi 自己的 Python 数据);方法的**名字**照 pi 保留 camelCase,
243
+ 因为那是扩展作者要背的那部分。
244
+ """
245
+ info = {"name": tool.name, "label": tool.display_label,
246
+ "description": tool.description,
247
+ "prompt_snippet": tool.prompt_snippet,
248
+ "prompt_guidelines": list(tool.prompt_guidelines),
249
+ "parameters": tool.parameters,
250
+ "source_info": dict(tool.source_info or {})}
251
+ # pi 的键名(驼峰)也放一份:qi 自己的数据用 snake_case,但照 pi 写的扩展
252
+ # (`t.promptGuidelines` / `t.sourceInfo`)也要能直接跑 —— 两套键指同一个值。
253
+ info["promptGuidelines"] = info["prompt_guidelines"]
254
+ info["sourceInfo"] = info["source_info"]
255
+ return info
256
+
257
+
258
+ def _note(host: Any, text: str) -> None:
259
+ """往宿主的 notes 通道丢一条可读提示(宿主可能没有这个属性)。"""
260
+ notes = getattr(host, "notes", None)
261
+ if isinstance(notes, list):
262
+ notes.append(text)
263
+
264
+
265
+ # ── 命令与快捷键(扩展向 TUI/CLI 注册入口)──────────────────
266
+
267
+ #: 命令 handler:`async def handler(args: str, ctx) -> None`
268
+ CommandHandler = Callable[[str, "ExtensionContext"], Any | Awaitable[Any]]
269
+ #: 快捷键 handler:`async def handler(ctx) -> None`
270
+ ShortcutHandler = Callable[["ExtensionContext"], Any | Awaitable[Any]]
271
+ #: 能力解析器(E20 的产物):拿到一个**作用域**(`scope`),交回该作用域下这个种类的工具。
272
+ #: (MCP 已改走按值注入 E25,不再用它;完整论证见 design/extensions-design.md §13。)
273
+ #: 返回 `list[Tool]` 或 awaitable。`scope` 的形态由提供方与消费方约定 —— qi 里通常是
274
+ #: 一个目录作用域(§10)。故意用 `...` 而不是固定签名:作用域这个参数到底长什么样,
275
+ #: 是提供方与消费方之间的事,core 不应当插进来定它。
276
+ ResolverFn = Callable[..., Any | Awaitable[Any]]
277
+ #: CLI 子命令处理器:收**命令名之后的原始 argv**(如 `["--port", "30142"]`),回退出码或 None。
278
+ #: 故意不接 typer/click 的解析结果:选项表是静态的,让扩展自己解析自己的参数是唯一稳的
279
+ #: 形状(E19 实测:动态改 typer 的选项表会把用户打错的选项变成别的东西)。
280
+ CliHandler = Callable[[list[str]], int | None]
281
+
282
+
283
+ @dataclass
284
+ class CliCommand:
285
+ """一个扩展注册的 CLI 子命令(`qi <name> …`)。"""
286
+
287
+ name: str
288
+ handler: CliHandler
289
+ description: str = ""
290
+ source: str = ""
291
+
292
+
293
+ class CliCommandRegistry:
294
+ """扩展 CLI 子命令的登记处(与 `CommandRegistry` 并列的宿主侧汇合点)。
295
+
296
+ **重名不编号、也不静默丢** —— 与斜杠命令的处理不同,因为 CLI 名字是用户敲进终端的
297
+ 第一个词,`qi web:1` 这种东西没法用。所以撞名是**错误**而不是排队:两个扩展都想叫
298
+ `web` 是用户必须知道并解决的事。
299
+ """
300
+
301
+ def __init__(self) -> None:
302
+ self._commands: dict[str, CliCommand] = {}
303
+
304
+ def add_command(self, name: str, handler: CliHandler, *,
305
+ description: str = "", source: str = "") -> bool:
306
+ """返回 `False` = 这个名字已被占(调用方据此报错,而不是默默丢掉一个)。"""
307
+ if name in self._commands:
308
+ return False
309
+ self._commands[name] = CliCommand(name=name, handler=handler,
310
+ description=description, source=source)
311
+ return True
312
+
313
+ def find(self, name: str) -> CliCommand | None:
314
+ return self._commands.get(name)
315
+
316
+ @property
317
+ def names(self) -> list[str]:
318
+ return sorted(self._commands)
319
+
320
+ def all(self) -> list[CliCommand]:
321
+ return [self._commands[n] for n in self.names]
322
+
323
+
324
+ @dataclass
325
+ class ExtensionCommand:
326
+ """一条扩展斜杠命令。`invocable` 是**实际能输入的**名字(重名时带 `:1`/`:2`)。"""
327
+
328
+ name: str # 原始名(扩展声明时的)
329
+ invocable: str # 加上后缀后的可输入名
330
+ description: str
331
+ handler: CommandHandler
332
+ source: str # 哪个扩展注册的(诊断/展示)
333
+ #: pi 的 `getArgumentCompletions(prefix) -> list | None`(参数补全)。qi 暂未消费,
334
+ #: 但**收下来** —— 注册了不生效比报错难查,所以 `get_commands()` 会把它带出去。
335
+ get_argument_completions: Any = None
336
+
337
+
338
+ @dataclass
339
+ class ExtensionShortcut:
340
+ """一条扩展快捷键。`key` 用 textual 的写法(如 `ctrl+shift+p`)。"""
341
+
342
+ key: str
343
+ description: str
344
+ handler: ShortcutHandler
345
+ source: str
346
+
347
+
348
+ @dataclass
349
+ class FlagSpec:
350
+ """扩展声明的一个 CLI 旗标(design/extensions-design.md §11.8 选定的 b 方案)。
351
+
352
+ qi **不往 typer 的选项表里动态加东西**(实测:typer 的 `get_command` 每次重建,
353
+ 往 `TyperGroup` 里塞裸 `click.Option` 会崩在它自己的内置属性上)。core 只静态声明
354
+ 一个 `--ext name=value`(可重复),扩展旗标走它。
355
+ """
356
+
357
+ name: str
358
+ type: str = "boolean" # boolean | string
359
+ default: Any = False
360
+ description: str = ""
361
+ source: str = ""
362
+
363
+
364
+ #: `--ext` 里接受的真/假写法。未列全的一律当“值非法”报出来,不静默取默认值。
365
+ _TRUTHY = ("true", "1", "yes", "on")
366
+ _FALSY = ("false", "0", "no", "off")
367
+
368
+
369
+ class FlagRegistry:
370
+ """扩展旗标的登记处 + `--ext` 传来的值的解析。
371
+
372
+ 两条设计:
373
+ * **未知旗标名会被拒绝**(`provide` 返回 False)—— 这是 `--ext` 方案保留
374
+ “打错就报错”那一层保护的地方:没有它,`--ext agnet=reviewer` 会静静地什么也不做。
375
+ * **值非法也报出来**(不静默取默认值):`--ext plan=maybe` 应该是错,
376
+ 而不是“用户以为开了、其实没开”。
377
+ """
378
+
379
+ def __init__(self) -> None:
380
+ self._specs: dict[str, FlagSpec] = {}
381
+ self._values: dict[str, Any] = {}
382
+ #: “提供了但没用上 / 用不对”的说明(调用方转成 notes 让人看见)
383
+ self.problems: list[str] = []
384
+
385
+ # ── 声明(扩展侧)──
386
+ def add(self, name: str, *, type: str = "boolean", default: Any = False,
387
+ description: str = "", source: str = "") -> None:
388
+ """重名时**第一条胜**,后来的记一条 problem(不静默丢掉,也不覆盖它)。"""
389
+ key = name.strip().lstrip("-")
390
+ if key in self._specs:
391
+ self.problems.append(
392
+ f"旗标 --{key} 已被 {self._specs[key].source} 声明"
393
+ f"({source} 的重复声明被忽略)")
394
+ return
395
+ self._specs[key] = FlagSpec(name=key, type=type, default=default,
396
+ description=description, source=source)
397
+
398
+ # ── 取值(扩展侧)──
399
+ def value(self, name: str) -> Any:
400
+ """旗标当前值:`--ext` 给的优先,否则声明的 default;未声明的 → None。"""
401
+ key = name.strip().lstrip("-")
402
+ if key in self._values:
403
+ return self._values[key]
404
+ spec = self._specs.get(key)
405
+ return spec.default if spec is not None else None
406
+
407
+ # ── 喂值(宿主侧,来自 `--ext`)──
408
+ def provide(self, pair: str) -> str | None:
409
+ """`name=value`(或裸 `name` = 布尔真)。**返回错误说明**(None = 没问题)。
410
+
411
+ 返回字符串而不是布尔值:调用方需要把这句话**原样报给用户**,自己再拼一遍
412
+ 容易和这里的判断漂开(新增一种非法写法时只改了一处)。
413
+ """
414
+ raw = pair.strip()
415
+ if not raw:
416
+ return None
417
+ name, sep, value = raw.partition("=")
418
+ key = name.strip().lstrip("-")
419
+ spec = self._specs.get(key)
420
+ if spec is None:
421
+ return (f"--ext {name} 没有对应的扩展旗标(是不是打错了?)"
422
+ + (f";已注册的是: {', '.join(self.names)}" if self._specs else
423
+ ";当前没有任何扩展声明旗标"))
424
+ if spec.type != "boolean":
425
+ if not sep:
426
+ # pi 同款(它报 `Extension flag "--x" requires a value`):字符串旗标缺值
427
+ # 要报错 —— 静默变成空串会表现为“我传了角色名,但它没生效”。
428
+ return (f"旗标 {key} 需要值(写成 --{key}=值 或 --ext {key}=值)")
429
+ self._values[key] = value
430
+ return None
431
+ if not sep: # `--ext plan` = 打开
432
+ self._values[key] = True
433
+ return None
434
+ lowered = value.strip().lower()
435
+ if lowered in _TRUTHY:
436
+ self._values[key] = True
437
+ return None
438
+ if lowered in _FALSY:
439
+ self._values[key] = False
440
+ return None
441
+ return (f"--ext {key}={value} 不是布尔值"
442
+ f"(接受 {'/'.join(_TRUTHY + _FALSY)};写 --ext {key} 即打开)")
443
+
444
+ def specs(self) -> list[FlagSpec]:
445
+ return [self._specs[k] for k in sorted(self._specs)]
446
+
447
+ @property
448
+ def names(self) -> list[str]:
449
+ return sorted(self._specs)
450
+
451
+ @property
452
+ def is_empty(self) -> bool:
453
+ return not self._specs
454
+
455
+
456
+ class CommandRegistry:
457
+ """扩展命令与快捷键的登记处(与 `CapabilityRegistry` 并列的一个宿主侧汇合点)。
458
+
459
+ **重名不覆盖,而是都留着并加序号**(`/review:1`、`/review:2`,对齐 pi):
460
+ 命令是用户显式输入的东西,静默丢掉一个会变成“我装了但打不出来”—— 而那种
461
+ 问题在现场是无法区分“没装”与“被覆盖”的。
462
+ """
463
+
464
+ def __init__(self) -> None:
465
+ self._commands: dict[str, list[ExtensionCommand]] = {}
466
+ self._shortcuts: list[ExtensionShortcut] = []
467
+
468
+ # ── 注册 ──
469
+ def add_command(self, name: str, handler: CommandHandler, *,
470
+ description: str = "", source: str = "",
471
+ get_argument_completions: Any = None) -> None:
472
+ self._commands.setdefault(name, []).append(ExtensionCommand(
473
+ name=name, invocable=name, description=description,
474
+ handler=handler, source=source,
475
+ get_argument_completions=get_argument_completions))
476
+ self._renumber(name)
477
+
478
+ def add_shortcut(self, key: str, handler: ShortcutHandler, *,
479
+ description: str = "", source: str = "") -> None:
480
+ self._shortcuts.append(ExtensionShortcut(
481
+ key=key, description=description, handler=handler, source=source))
482
+
483
+ def _renumber(self, name: str) -> None:
484
+ """同名命令一律带序号(`:1`、`:2`…)—— 只有一个时不加。"""
485
+ holders = self._commands.get(name, [])
486
+ for index, command in enumerate(holders, start=1):
487
+ command.invocable = name if len(holders) == 1 else f"{name}:{index}"
488
+
489
+ # ── 查询 ──
490
+ def all(self) -> list[ExtensionCommand]:
491
+ """全部命令(按可输入名排序)。"""
492
+ out = [c for holders in self._commands.values() for c in holders]
493
+ return sorted(out, key=lambda c: c.invocable)
494
+
495
+ def find(self, invocable: str) -> ExtensionCommand | None:
496
+ """按**可输入名**查(`review:2` 也认;`review` 在重名时查不到,要写全)。"""
497
+ key = invocable.strip().lstrip("/")
498
+ return next((c for c in self.all() if c.invocable == key), None)
499
+
500
+ @property
501
+ def names(self) -> list[str]:
502
+ return [c.invocable for c in self.all()]
503
+
504
+ def shortcuts(self) -> list[ExtensionShortcut]:
505
+ return list(self._shortcuts)
506
+
507
+ @property
508
+ def is_empty(self) -> bool:
509
+ return not self._commands and not self._shortcuts
510
+
511
+
512
+ class RendererRegistry:
513
+ """渲染回调的登记处(pi 的 `registerMessageRenderer` / `registerEntryRenderer` /
514
+ `registerMarkdownTransformer`)。
515
+
516
+ **TUI-only**:回调返回的是前端组件,所以非 TUI 前端退回默认渲染(与 pi 的
517
+ `ctx.mode` 门控同一口径)。这里只负责**登记** —— 什么时候调由前端决定。
518
+
519
+ 同名后注册者胜(pi 的行为):一个扩展重注册自己的渲染器是常见写法。
520
+ """
521
+
522
+ def __init__(self) -> None:
523
+ self._messages: dict[str, Any] = {}
524
+ self._entries: dict[str, Any] = {}
525
+ self._markdown: list[Any] = []
526
+
527
+ def add_message(self, custom_type: str, renderer: Any, *, source: str = "") -> None:
528
+ self._messages[str(custom_type)] = (renderer, source)
529
+
530
+ def add_entry(self, custom_type: str, renderer: Any, *, source: str = "") -> None:
531
+ self._entries[str(custom_type)] = (renderer, source)
532
+
533
+ def add_markdown(self, transformer: Any, *, source: str = "") -> None:
534
+ self._markdown.append((transformer, source))
535
+
536
+ def message_renderer(self, custom_type: str) -> Any | None:
537
+ found = self._messages.get(str(custom_type))
538
+ return found[0] if found else None
539
+
540
+ def entry_renderer(self, custom_type: str) -> Any | None:
541
+ found = self._entries.get(str(custom_type))
542
+ return found[0] if found else None
543
+
544
+ def transformers(self) -> list[Any]:
545
+ return [fn for fn, _ in self._markdown]
546
+
547
+ def apply_markdown(self, markdown: str, ctx: Any = None) -> str:
548
+ """按注册顺序链式跑 markdown transformer(单个抛错只跳过它)。"""
549
+ text = markdown
550
+ for fn, source in self._markdown:
551
+ try:
552
+ text = str(fn(text, ctx) if ctx is not None else fn(text))
553
+ except Exception: # noqa: BLE001 渲染器坏不该把消息弄丢
554
+ continue
555
+ return text
556
+
557
+ @property
558
+ def is_empty(self) -> bool:
559
+ return not self._messages and not self._entries and not self._markdown
560
+
561
+
562
+ #: 单个子进程 stdout/stderr 的捕获上限(与工具输出同档)
563
+ MAX_EXEC_OUTPUT = 50_000
564
+
565
+
566
+ @dataclass
567
+ class ExecResult:
568
+ """`api.exec()` 的结果(pi 的 `pi.exec` 同形)。"""
569
+
570
+ command: str
571
+ args: list[str]
572
+ code: int | None # None = 被信号杀掉(含超时/中断)
573
+ stdout: str
574
+ stderr: str
575
+ killed: bool = False
576
+ duration_ms: int = 0
577
+
578
+ @property
579
+ def ok(self) -> bool:
580
+ return self.code == 0
581
+
582
+
583
+ async def exec_command(command: str, args: Sequence[str] | None = None, *,
584
+ cwd: str | Path | None = None, timeout: float | None = None,
585
+ signal: AbortSignal | None = None,
586
+ env: dict[str, str] | None = None) -> ExecResult:
587
+ """起一个**不经 shell** 的子进程(对齐 pi 的 `pi.exec`)。
588
+
589
+ 不经 shell 是刻意的:`args` 原样进 argv,扩展不必自己拼引号 —— 也就不会因为少转义
590
+ 一个空格而执行了别的命令。要 shell 语义就显式 `bash -c`。
591
+
592
+ `timeout` 与 `signal` 任一命中都**杀进程**并把 `killed` 置真(`code` 为 None):
593
+ 协作式取消在这里体现为`kill()`,因为子进程不会自己检查 Python 的 flag。
594
+ """
595
+ argv = [command, *[str(a) for a in (args or ())]]
596
+ started = time.perf_counter_ns()
597
+
598
+ def elapsed() -> int:
599
+ return (time.perf_counter_ns() - started) // 1_000_000
600
+
601
+ proc = await asyncio.create_subprocess_exec(
602
+ *argv, cwd=str(cwd) if cwd else None,
603
+ env={**os.environ, **env} if env else None,
604
+ stdin=asyncio.subprocess.DEVNULL,
605
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
606
+
607
+ comm = asyncio.ensure_future(proc.communicate())
608
+ watcher = asyncio.ensure_future(signal.wait()) if signal is not None else None
609
+ killed = False
610
+ try:
611
+ waiters: set[asyncio.Future] = {comm}
612
+ if watcher is not None:
613
+ waiters.add(watcher)
614
+ done, _pending = await asyncio.wait(waiters, timeout=timeout,
615
+ return_when=asyncio.FIRST_COMPLETED)
616
+ if comm not in done:
617
+ # 超时或中断 —— 子进程不会看 Python 的中断标志,所以只能杀
618
+ killed = True
619
+ proc.kill()
620
+ out, err = await comm
621
+ finally:
622
+ if watcher is not None:
623
+ watcher.cancel()
624
+ with contextlib.suppress(Exception, asyncio.CancelledError):
625
+ await watcher
626
+
627
+ return ExecResult(
628
+ command=command, args=argv[1:],
629
+ code=None if killed else proc.returncode,
630
+ stdout=_decode(out), stderr=_decode(err),
631
+ killed=killed, duration_ms=elapsed())
632
+
633
+
634
+ def _decode(raw: bytes) -> str:
635
+ text = raw.decode("utf-8", errors="replace")
636
+ if len(text) > MAX_EXEC_OUTPUT:
637
+ return text[:MAX_EXEC_OUTPUT] + "\n…(输出已截断)"
638
+ return text
639
+
640
+
641
+ def _handler_name(handler: Any) -> str:
642
+ """handler 的可读名字(诊断用):优先模块.qualname。"""
643
+ module = getattr(handler, "__module__", "") or ""
644
+ qual = getattr(handler, "__qualname__", None) or getattr(handler, "__name__", "") or "?"
645
+ return f"{module}.{qual}" if module else str(qual)
646
+
647
+
648
+ def _is_verdict(returned: dict, stop_keys: tuple[str, ...] | None,
649
+ stop_values: dict[str, tuple[str, ...]] | None) -> bool:
650
+ """这次返回算不算“裁决”(要停链)?两种形状的差别见 `emit_until`。"""
651
+ if stop_keys and any(returned.get(k) for k in stop_keys):
652
+ return True
653
+ for key, allowed in (stop_values or {}).items():
654
+ if returned.get(key) in allowed:
655
+ return True
656
+ return False
657
+
658
+
659
+ class ExtensionUi:
660
+ """`ctx.ui` —— 扩展向前端要交互的唯一入口(docs/extensions.md §8)。
661
+
662
+ 分两层(与 pi 一致):
663
+
664
+ * **数据层** —— 问答(`confirm`/`select`/`input`/`editor`)与状态(`notify`/
665
+ `set_status`/`set_title`/`set_working_*`/`set_hidden_thinking_label`/
666
+ `get_set_tools_expanded`/主题)。跨前端:TUI 与 web 都该实现;没前端时退回确定值。
667
+ * **组件层** —— `set_widget` / `set_footer` / `set_header` / `custom` /
668
+ `set_editor_component` / `add_autocomplete_provider` / `on_terminal_input`。
669
+ **TUI-only**(pi 同样只在 `ctx.mode === "tui"` 下有意义):其他前端下 `custom()`
670
+ 返回 None、注册类调用记一条 note 后 no-op —— 不静默。
671
+
672
+ 两条硬规则(§4 同级的契约,有测试钉住):
673
+
674
+ 1. **没有后端时每个问答方法返回调用方给的 `default`**,于是“交互”退化成“按事先
675
+ 声明好的策略走”,而**永远不会挂住**。`confirm` 的 `default` 默认 **False**
676
+ (拒绝是安全边);`select`/`input`/`editor` 默认 None(取消)。默认值由**调用方**
677
+ 给 —— 只有它知道“这里是拒绝安全还是继续安全”。
678
+ 2. `notify` 没有后端时**落进 `notes`**,不丢弃(否则“扩展说了一句话”就凭空消失)。
679
+
680
+ 后端自己抛异常也走 `default`(记一条 note):交互是辅助手段,不该成为新的失败点。
681
+ """
682
+
683
+ def __init__(self, frontend: Any = None, notes: list[str] | None = None,
684
+ mode: str = "print", bus: Any = None, ctx_factory: Any = None) -> None:
685
+ self._frontend = frontend
686
+ self._notes = notes if notes is not None else []
687
+ self.mode = mode
688
+ #: `ui_prompt_start` / `ui_prompt_end` 的派发面(pi 用来观察“有人在等交互”)。
689
+ #: 没给总线就只有交互,不发事件。
690
+ self._bus = bus
691
+ self._ctx_factory = ctx_factory
692
+
693
+ async def _emit_prompt(self, phase: str, kind: str, title: str | None) -> None:
694
+ """发 `ui_prompt_start` / `ui_prompt_end`(失败不影响交互本身)。"""
695
+ event = f"ui_prompt_{phase}"
696
+ if self._bus is None or not self._bus.has(event):
697
+ return
698
+ ctx = self._ctx_factory() if callable(self._ctx_factory) else None
699
+ if ctx is None:
700
+ return
701
+ try:
702
+ await self._bus.emit(event,
703
+ {"reason": "ui_prompt", "kind": kind, "title": title},
704
+ ctx=ctx)
705
+ except Exception: # noqa: BLE001 通知失败不该影响交互
706
+ return
707
+
708
+ @property
709
+ def frontend(self) -> Any:
710
+ return self._frontend
711
+
712
+ @frontend.setter
713
+ def frontend(self, value: Any) -> None:
714
+ self._frontend = value
715
+
716
+ @property
717
+ def has_frontend(self) -> bool:
718
+ """有人能弹交互吗(与 `ctx.has_ui` 不同:后者是“有人在看”)。"""
719
+ return self._frontend is not None
720
+
721
+ def _report(self, method: str, exc: Exception) -> None:
722
+ self._notes.append(f"扩展的 ui.{method} 前端处理失败: {type(exc).__name__}: {exc}")
723
+
724
+ def _forward(self, method: str, *args: Any, **kwargs: Any) -> tuple[bool, Any]:
725
+ """转给前端。返回 `(拿到值了吗, 值)` —— 前端没有这个方法就是 `(False, None)`。"""
726
+ fn = getattr(self._frontend, method, None)
727
+ if not callable(fn):
728
+ return False, None
729
+ try:
730
+ return True, fn(*args, **kwargs)
731
+ except Exception as exc: # noqa: BLE001 界面坏不等于回合该失败
732
+ self._report(method, exc)
733
+ return True, None
734
+
735
+ def _component_unavailable(self, method: str) -> None:
736
+ """组件层在当前前端上不可用时的统一处理:**不静默**(组件被丢了是功能损失)。"""
737
+ self._notes.append(
738
+ f"ui.{method} 需要 TUI 前端且该前端实现了它(ctx.mode={self.mode!r});已忽略")
739
+
740
+ def _forward_component(self, method: str, *args: Any) -> bool:
741
+ """组件层转发:前端没实现这个方法时**记一条 note**并返回 False。"""
742
+ if self._frontend is None or self.mode != "tui":
743
+ self._component_unavailable(method)
744
+ return False
745
+ found, _ = self._forward(method, *args)
746
+ if not found:
747
+ self._component_unavailable(method)
748
+ return False
749
+ return True
750
+
751
+ # ── 数据层:问答 ──
752
+ async def confirm(self, message: str, *, title: str | None = None,
753
+ default: bool = False) -> bool:
754
+ """问一个是/否。**默认 False** —— “拿不准就不做”是安全边。
755
+
756
+ `title` 与 `message` 的顺序照 qi 的写法(message 必填);pi 是
757
+ `confirm(title, message)` —— 两个都收(见下 `confirm_title_first` 说明)。
758
+ """
759
+ if self._frontend is None:
760
+ return default
761
+ await self._emit_prompt("start", "confirm", title)
762
+ try:
763
+ value = await self._frontend.confirm(message, title=title, default=default)
764
+ except Exception as exc: # noqa: BLE001 界面坏不等于回合该失败
765
+ self._report("confirm", exc)
766
+ return default
767
+ finally:
768
+ await self._emit_prompt("end", "confirm", title)
769
+ return default if value is None else bool(value)
770
+
771
+ async def select(self, message: str, options: Sequence[str], *,
772
+ title: str | None = None, default: str | None = None) -> str | None:
773
+ """让用户从 `options` 里选一个。取消 → None。"""
774
+ if self._frontend is None:
775
+ return default
776
+ await self._emit_prompt("start", "select", title)
777
+ try:
778
+ value = await self._frontend.select(message, list(options),
779
+ title=title, default=default)
780
+ except Exception as exc: # noqa: BLE001
781
+ self._report("select", exc)
782
+ return default
783
+ finally:
784
+ await self._emit_prompt("end", "select", title)
785
+ return default if value is None else str(value)
786
+
787
+ async def input(self, message: str, *, title: str | None = None,
788
+ default: str | None = None, secret: bool = False) -> str | None:
789
+ """要一行文本。取消 → None(注意:空字符串是“用户按了回车”,不是取消)。"""
790
+ if self._frontend is None:
791
+ return default
792
+ await self._emit_prompt("start", "input", title)
793
+ try:
794
+ value = await self._frontend.input(message, title=title, default=default,
795
+ secret=secret)
796
+ except Exception as exc: # noqa: BLE001
797
+ self._report("input", exc)
798
+ return default
799
+ finally:
800
+ await self._emit_prompt("end", "input", title)
801
+ return default if value is None else str(value)
802
+
803
+ async def editor(self, prefill: str = "", *, title: str | None = None) -> str | None:
804
+ """多行编辑(pi 的 `ui.editor(title, prefill)`)。无前端 → None。"""
805
+ if self._frontend is None:
806
+ return None
807
+ await self._emit_prompt("start", "editor", title)
808
+ try:
809
+ found, value = self._forward("editor", prefill, title=title)
810
+ if not found: # 前端没实现多行 → 退回单行 input
811
+ return await self.input(title or "编辑", default=prefill)
812
+ return None if value is None else str(value)
813
+ finally:
814
+ await self._emit_prompt("end", "editor", title)
815
+
816
+ def notify(self, message: str, *, level: str = "info") -> None:
817
+ """说一句不需要回答的话。无界面时进 `notes`,**不丢弃**。"""
818
+ if self._frontend is None:
819
+ self._notes.append(message)
820
+ return
821
+ try:
822
+ self._frontend.notify(message, level=level)
823
+ except Exception as exc: # noqa: BLE001
824
+ self._report("notify", exc)
825
+ self._notes.append(message)
826
+
827
+ # ── 数据层:状态与外观 ──
828
+ def set_status(self, key: str, text: str | None) -> None:
829
+ """footer/状态栏里的一条常驻状态(`text=None` 清除)。"""
830
+ if self._frontend is None:
831
+ return
832
+ self._forward("set_status", key, text)
833
+
834
+ def set_title(self, title: str) -> None:
835
+ """终端窗口/标签页标题。"""
836
+ if self._frontend is None:
837
+ return
838
+ self._forward("set_title", title)
839
+
840
+ def set_working_message(self, message: str | None = None) -> None:
841
+ """流式过程中那一行“正在做事”的文案(不传 = 恢复默认)。"""
842
+ if self._frontend is None:
843
+ return
844
+ self._forward("set_working_message", message)
845
+
846
+ def set_working_visible(self, visible: bool) -> None:
847
+ if self._frontend is None:
848
+ return
849
+ self._forward("set_working_visible", visible)
850
+
851
+ def set_working_indicator(self, options: dict | None = None) -> None:
852
+ """动画帧/间隔配置(`{"frames": [...], "intervalMs": int}`)。"""
853
+ if self._frontend is None:
854
+ return
855
+ self._forward("set_working_indicator", options)
856
+
857
+ def set_hidden_thinking_label(self, label: str | None = None) -> None:
858
+ """折叠思考块显示的那个短标签。"""
859
+ if self._frontend is None:
860
+ return
861
+ self._forward("set_hidden_thinking_label", label)
862
+
863
+ def get_tools_expanded(self) -> bool:
864
+ return bool(self._forward("get_tools_expanded")[1]) if self._frontend else False
865
+
866
+ def set_tools_expanded(self, expanded: bool) -> None:
867
+ if self._frontend is None:
868
+ return
869
+ self._forward("set_tools_expanded", expanded)
870
+
871
+ @property
872
+ def theme(self) -> Any:
873
+ """当前主题(无前端 / 无该能力 → None)。
874
+
875
+ 注意走 `getattr` 而不是 `_forward`:pi 的 `ui.theme` 是**属性**,前端的也是属性
876
+ (取到的是 Palette,不可调用) —— 拿 `_forward` 会被 `callable` 判掉而永远返回 None。
877
+ """
878
+ if self._frontend is None:
879
+ return None
880
+ return getattr(self._frontend, "theme", None)
881
+
882
+ def get_all_themes(self) -> list[dict]:
883
+ value = self._forward("get_all_themes")[1] if self._frontend else None
884
+ return list(value) if isinstance(value, list) else []
885
+
886
+ def get_theme(self, name: str) -> Any:
887
+ return self._forward("get_theme", name)[1] if self._frontend else None
888
+
889
+ def set_theme(self, theme: Any) -> dict:
890
+ """切主题。返回 `{success, error?}`(pi 同形)。"""
891
+ if self._frontend is None:
892
+ return {"success": False, "error": "没有前端"}
893
+ found, value = self._forward("set_theme", theme)
894
+ if not found:
895
+ return {"success": False, "error": "前端不支持切主题"}
896
+ return dict(value) if isinstance(value, dict) else {"success": bool(value)}
897
+
898
+ # ── 数据层:编辑器内容 ──
899
+ def paste_to_editor(self, text: str) -> None:
900
+ if self._frontend is None:
901
+ return
902
+ self._forward("paste_to_editor", text)
903
+
904
+ def set_editor_text(self, text: str) -> None:
905
+ if self._frontend is None:
906
+ return
907
+ self._forward("set_editor_text", text)
908
+
909
+ def get_editor_text(self) -> str:
910
+ value = self._forward("get_editor_text")[1] if self._frontend else None
911
+ return "" if value is None else str(value)
912
+
913
+ # ── 组件层(TUI-only)──
914
+ def set_widget(self, key: str, content: Any,
915
+ options: dict | None = None) -> None:
916
+ """在编辑器上/下方挂一个常驻 widget(pi 的 `setWidget`;`content=None` 移除)。
917
+
918
+ `content` 是字符串行列表,或一个 `(ctx) -> 组件` 的工厂。
919
+ """
920
+ self._forward_component("set_widget", key, content, options)
921
+
922
+ def set_footer(self, factory: Any) -> None:
923
+ """整个替换 footer(`None` = 恢复内置)。"""
924
+ self._forward_component("set_footer", factory)
925
+
926
+ def set_header(self, factory: Any) -> None:
927
+ """整个替换启动 header(`None` = 恢复内置)。"""
928
+ self._forward_component("set_header", factory)
929
+
930
+ async def custom(self, factory: Any, options: dict | None = None) -> Any:
931
+ """弹一个扩展自己的组件(可带键盘焦点 / overlay)。无 TUI → None(pi 同形)。"""
932
+ if self._frontend is None or self.mode != "tui":
933
+ return None
934
+ found, value = self._forward("custom", factory, options)
935
+ if not found:
936
+ self._component_unavailable("custom")
937
+ return None
938
+ await self._emit_prompt("start", "custom", None)
939
+ try:
940
+ return await value if inspect.isawaitable(value) else value
941
+ finally:
942
+ await self._emit_prompt("end", "custom", None)
943
+
944
+ def set_editor_component(self, factory: Any) -> None:
945
+ """把输入框换成扩展给的组件(`None` = 恢复内置)。
946
+
947
+ **要求组件是 `TextArea`(或它的子类)** —— qi 的编辑器是承重的(历史环 /
948
+ kill-ring / 补全 / 光标定位),把边界画在这里,调用点就不必各自做兼容。
949
+ 不满足时记一条 note 并 no-op(不静默)。
950
+ """
951
+ self._forward_component("set_editor_component", factory)
952
+
953
+ def get_editor_component(self) -> Any:
954
+ """当前的自定义编辑器工厂(`None` = 内置)。"""
955
+ return self._forward("get_editor_component")[1] if self._frontend else None
956
+
957
+ def add_autocomplete_provider(self, factory: Any) -> Any:
958
+ """注册一个补全提供者。**返回退订函数**(qi 的增量:pi 返回 void)。
959
+
960
+ qi 的契约:`factory(text, cursor_offset) -> list[str | {value, label, description}]`
961
+ (与命令的 `getArgumentCompletions` 同一套元素形状 —— 两种补全只学一次)。
962
+ pi 那个形状(包住一个 pi-tui 的 `AutocompleteProvider` 对象)不能镜像:它暴露的是
963
+ pi-tui 的补全内部结构,而 qi 的补全管线是另一套。
964
+ """
965
+ if self._frontend is None or self.mode != "tui":
966
+ self._component_unavailable("add_autocomplete_provider")
967
+ return lambda: None
968
+ found, value = self._forward("add_autocomplete_provider", factory)
969
+ if not found:
970
+ self._component_unavailable("add_autocomplete_provider")
971
+ return lambda: None
972
+ return value if callable(value) else (lambda: None)
973
+
974
+ def on_terminal_input(self, handler: Any) -> Any:
975
+ """听原始终端输入。返回**退订函数**(pi 同形);不可用时返回 no-op 函数。
976
+
977
+ 不可用不是静默的 —— 读键盘是功能性的(不是装饰),丢了该看得见。
978
+ """
979
+ if self._frontend is None or self.mode != "tui":
980
+ self._component_unavailable("on_terminal_input")
981
+ return lambda: None
982
+ found, value = self._forward("on_terminal_input", handler)
983
+ if not found:
984
+ self._component_unavailable("on_terminal_input")
985
+ return lambda: None
986
+ return value if callable(value) else (lambda: None)
987
+
988
+ # ── pi 的驼峰别名 ──
989
+ def setStatus(self, key: str, text: str | None) -> None: # noqa: N802
990
+ self.set_status(key, text)
991
+
992
+ def setTitle(self, title: str) -> None: # noqa: N802
993
+ self.set_title(title)
994
+
995
+ def setWorkingMessage(self, message: str | None = None) -> None: # noqa: N802
996
+ self.set_working_message(message)
997
+
998
+ def setWorkingVisible(self, visible: bool) -> None: # noqa: N802
999
+ self.set_working_visible(visible)
1000
+
1001
+ def setWorkingIndicator(self, options: dict | None = None) -> None: # noqa: N802
1002
+ self.set_working_indicator(options)
1003
+
1004
+ def setHiddenThinkingLabel(self, label: str | None = None) -> None: # noqa: N802
1005
+ self.set_hidden_thinking_label(label)
1006
+
1007
+ def getToolsExpanded(self) -> bool: # noqa: N802
1008
+ return self.get_tools_expanded()
1009
+
1010
+ def setToolsExpanded(self, expanded: bool) -> None: # noqa: N802
1011
+ self.set_tools_expanded(expanded)
1012
+
1013
+ def getAllThemes(self) -> list[dict]: # noqa: N802
1014
+ return self.get_all_themes()
1015
+
1016
+ def getTheme(self, name: str) -> Any: # noqa: N802
1017
+ return self.get_theme(name)
1018
+
1019
+ def setTheme(self, theme: Any) -> dict: # noqa: N802
1020
+ return self.set_theme(theme)
1021
+
1022
+ def pasteToEditor(self, text: str) -> None: # noqa: N802
1023
+ self.paste_to_editor(text)
1024
+
1025
+ def setEditorText(self, text: str) -> None: # noqa: N802
1026
+ self.set_editor_text(text)
1027
+
1028
+ def getEditorText(self) -> str: # noqa: N802
1029
+ return self.get_editor_text()
1030
+
1031
+ def setWidget(self, key: str, content: Any, options: dict | None = None) -> None: # noqa: N802
1032
+ self.set_widget(key, content, options)
1033
+
1034
+ def setFooter(self, factory: Any) -> None: # noqa: N802
1035
+ self.set_footer(factory)
1036
+
1037
+ def setHeader(self, factory: Any) -> None: # noqa: N802
1038
+ self.set_header(factory)
1039
+
1040
+ def setEditorComponent(self, factory: Any) -> None: # noqa: N802
1041
+ self.set_editor_component(factory)
1042
+
1043
+ def getEditorComponent(self) -> Any: # noqa: N802
1044
+ return self.get_editor_component()
1045
+
1046
+ def addAutocompleteProvider(self, factory: Any) -> None: # noqa: N802
1047
+ self.add_autocomplete_provider(factory)
1048
+
1049
+ def onTerminalInput(self, handler: Any) -> Any: # noqa: N802
1050
+ return self.on_terminal_input(handler)
1051
+
1052
+
1053
+ class ModelView(str):
1054
+ """`ctx.model` —— pi 那边是一个 `Model` 对象(qi 内部是 `ResolvedModel`)。
1055
+
1056
+ 做成 `str` 的子类是刻意的:qi 既有用法(`ctx.model == "provider/model"`、直接
1057
+ 进 JSON / 进提示词)一字不变,而 pi 的字段访问(`ctx.model.id` /
1058
+ `ctx.model.contextWindow`)也成立 —— 两边的语义同时满足,而不是二选一。
1059
+ """
1060
+
1061
+ # 类级注解:str 子类不能用 `__slots__`,所以用注解把动态属性告诉类型检查器
1062
+ provider: str
1063
+ id: str
1064
+ name: str
1065
+ api: str
1066
+ base_url: str | None
1067
+ reasoning: bool
1068
+ context_window: int
1069
+ max_tokens: int
1070
+
1071
+ def __new__(cls, provider: str, model_id: str, *, api: str = "",
1072
+ base_url: str | None = None, reasoning: bool = False,
1073
+ context_window: int = 0, max_tokens: int = 0, name: str = "") -> "ModelView":
1074
+ obj = super().__new__(cls, f"{provider}/{model_id}")
1075
+ obj.provider = provider
1076
+ obj.id = model_id # pi 的 `model.id`
1077
+ obj.name = name or model_id
1078
+ obj.api = api
1079
+ obj.base_url = base_url
1080
+ obj.reasoning = reasoning
1081
+ obj.context_window = context_window
1082
+ obj.max_tokens = max_tokens
1083
+ return obj
1084
+
1085
+ @classmethod
1086
+ def from_resolved(cls, resolved: Any) -> "ModelView":
1087
+ """从内部的 `ResolvedModel` 造一个(str 子类,所以 `== "p/m"` 也真)。"""
1088
+ if resolved is None:
1089
+ raise ValueError("from_resolved 需要 ResolvedModel,收到 None")
1090
+ entry = getattr(resolved, "entry", None)
1091
+ return cls(getattr(resolved, "provider", "") or "",
1092
+ getattr(resolved, "model", "") or "",
1093
+ api=getattr(resolved, "api", "") or "",
1094
+ base_url=getattr(resolved, "base_url", None),
1095
+ reasoning=bool(getattr(resolved, "reasoning", False)),
1096
+ context_window=_coerce_int(getattr(resolved, "context_window", 0)),
1097
+ max_tokens=_coerce_int(getattr(resolved, "max_tokens", 0)),
1098
+ name=str(getattr(entry, "name", "") or ""))
1099
+
1100
+ # ── pi 的字段名(驼峰)──
1101
+ @property
1102
+ def model(self) -> str:
1103
+ return self.id
1104
+
1105
+ @property
1106
+ def contextWindow(self) -> int: # noqa: N802
1107
+ return self.context_window
1108
+
1109
+ @property
1110
+ def maxTokens(self) -> int: # noqa: N802
1111
+ return self.max_tokens
1112
+
1113
+ @property
1114
+ def baseUrl(self) -> str | None: # noqa: N802
1115
+ return self.base_url
1116
+
1117
+ @property
1118
+ def label(self) -> str:
1119
+ return str(self)
1120
+
1121
+
1122
+ class SessionView:
1123
+ """`ctx.session_manager` —— 当前会话的**只读**视图。
1124
+
1125
+ 为什么不让扩展直接拿 `Session` / `SessionStore`:
1126
+ * 读——扩展要的只是“我说过什么、会话叫什么、分到哪个文件”,不需要知道 entry 形状;
1127
+ * 写——写口只有 `api.append_entry` **一个**(章由宿主盖:agent 归属、source、落盘时机)。
1128
+ 两个写口迟早写出两种 entry 形状。
1129
+
1130
+ `entries()` 返回**当前分支**(不是整个文件):会话是树,扩展没理由看到别的分支。
1131
+ 没有活动会话时读返回空/None —— 写由 `api.append_entry` 报错(不静默丢弃)。
1132
+
1133
+ 方法名两面都有:qi 的 snake_case 是正式名,pi 的驼峰是别名(`getEntry` /
1134
+ `getBranch` / …),所以照 pi 写的扩展能直接跑。
1135
+ """
1136
+
1137
+ def __init__(self, session: Any = None) -> None:
1138
+ self._session = session
1139
+
1140
+ @property
1141
+ def available(self) -> bool:
1142
+ """有活动会话吗(回合外没有)。"""
1143
+ return self._session is not None
1144
+
1145
+ @property
1146
+ def session_id(self) -> str | None:
1147
+ return getattr(self._session, "id", None)
1148
+
1149
+ @property
1150
+ def path(self) -> str | None:
1151
+ p = getattr(self._session, "path", None)
1152
+ return str(p) if p is not None else None
1153
+
1154
+ @property
1155
+ def title(self) -> str:
1156
+ return str(getattr(self._session, "title", "") or "")
1157
+
1158
+ def entries(self) -> list[dict]:
1159
+ """当前分支的全部 entry(原样,宿主不裁剪) —— 扩展自己按 `type` 过滤。"""
1160
+ if self._session is None:
1161
+ return []
1162
+ return [dict(e) for e in self._session.branch()]
1163
+
1164
+ def custom_entries(self, custom_type: str | None = None) -> list[dict]:
1165
+ """只取 `custom` entry(可再按 `custom_type` 过滤)—— 扩展存状态的常规做法。"""
1166
+ out = [e for e in self.entries() if e.get("type") == "custom"]
1167
+ if custom_type is not None:
1168
+ out = [e for e in out if e.get("custom_type") == custom_type]
1169
+ return out
1170
+
1171
+ # ── pi ReadonlySessionManager 对应面 ──
1172
+ def get_entries(self) -> list[dict]:
1173
+ return self.entries()
1174
+
1175
+ def get_entry(self, entry_id: str) -> dict | None:
1176
+ for entry in getattr(self._session, "entries", []) or []:
1177
+ if str(entry.get("id")) == str(entry_id):
1178
+ return dict(entry)
1179
+ return None
1180
+
1181
+ def get_leaf_id(self) -> str | None:
1182
+ return getattr(self._session, "leaf", None)
1183
+
1184
+ def get_leaf_entry(self) -> dict | None:
1185
+ leaf = self.get_leaf_id()
1186
+ return self.get_entry(leaf) if leaf else None
1187
+
1188
+ def get_branch(self, leaf: str | None = None) -> list[dict]:
1189
+ if self._session is None:
1190
+ return []
1191
+ try:
1192
+ return [dict(e) for e in self._session.branch(leaf)]
1193
+ except TypeError: # 测试替身可能不收参数
1194
+ return self.entries()
1195
+
1196
+ def build_context_entries(self) -> list[dict]:
1197
+ """进 LLM 上下文的那一串(= 当前分支)。"""
1198
+ return self.entries()
1199
+
1200
+ def get_header(self) -> dict | None:
1201
+ entries = getattr(self._session, "entries", []) or []
1202
+ if entries and entries[0].get("type") == "session":
1203
+ return dict(entries[0])
1204
+ return None
1205
+
1206
+ def get_cwd(self) -> str | None:
1207
+ cwd = getattr(self._session, "cwd", None)
1208
+ return str(cwd) if cwd is not None else None
1209
+
1210
+ def get_session_dir(self) -> str | None:
1211
+ p = getattr(self._session, "path", None)
1212
+ return str(Path(p).parent) if p is not None else None
1213
+
1214
+ def get_session_file(self) -> str | None:
1215
+ return self.path
1216
+
1217
+ def get_session_name(self) -> str | None:
1218
+ return self.title or None
1219
+
1220
+ def get_label(self, entry_id: str) -> str | None:
1221
+ """某条 entry 的 label(label entry 盖在 targetId 上,后写者胜)。"""
1222
+ found: str | None = None
1223
+ for entry in getattr(self._session, "entries", []) or []:
1224
+ if entry.get("type") == "label" and str(entry.get("targetId")) == str(entry_id):
1225
+ found = entry.get("label")
1226
+ return found
1227
+
1228
+ def get_tree(self) -> list[dict]:
1229
+ """嵌套树(`[{entry, children, label}]`),给 `/tree` 那类渲染用。"""
1230
+ session = self._session
1231
+ if session is None:
1232
+ return []
1233
+ nodes = list(getattr(session, "tree_entries", []))
1234
+ children: dict[str | None, list[dict]] = {}
1235
+ for entry in nodes:
1236
+ children.setdefault(entry.get("parentId") or None, []).append(entry)
1237
+
1238
+ def build(entry: dict) -> dict:
1239
+ node = {"entry": dict(entry), "children": []}
1240
+ label = self.get_label(str(entry.get("id")))
1241
+ if label is not None:
1242
+ node["label"] = label
1243
+ node["children"] = [build(c) for c in children.get(str(entry.get("id")), [])]
1244
+ return node
1245
+
1246
+ return [build(e) for e in children.get(None, [])]
1247
+
1248
+ # ── pi 的驼峰别名 ──
1249
+ def getSessionId(self) -> str | None: # noqa: N802
1250
+ return self.session_id
1251
+
1252
+ def getSessionFile(self) -> str | None: # noqa: N802
1253
+ return self.path
1254
+
1255
+ def getSessionName(self) -> str | None: # noqa: N802
1256
+ return self.get_session_name()
1257
+
1258
+ def getCwd(self) -> str | None: # noqa: N802
1259
+ return self.get_cwd()
1260
+
1261
+ def getEntries(self) -> list[dict]: # noqa: N802
1262
+ return self.entries()
1263
+
1264
+ def getEntry(self, entry_id: str) -> dict | None: # noqa: N802
1265
+ return self.get_entry(entry_id)
1266
+
1267
+ def getLeafId(self) -> str | None: # noqa: N802
1268
+ return self.get_leaf_id()
1269
+
1270
+ def getLeafEntry(self) -> dict | None: # noqa: N802
1271
+ return self.get_leaf_entry()
1272
+
1273
+ def getBranch(self, leaf: str | None = None) -> list[dict]: # noqa: N802
1274
+ return self.get_branch(leaf)
1275
+
1276
+ def buildContextEntries(self) -> list[dict]: # noqa: N802
1277
+ return self.build_context_entries()
1278
+
1279
+ def getHeader(self) -> dict | None: # noqa: N802
1280
+ return self.get_header()
1281
+
1282
+ def getLabel(self, entry_id: str) -> str | None: # noqa: N802
1283
+ return self.get_label(entry_id)
1284
+
1285
+ def getTree(self) -> list[dict]: # noqa: N802
1286
+ return self.get_tree()
1287
+
1288
+ def getSessionDir(self) -> str | None: # noqa: N802
1289
+ return self.get_session_dir()
1290
+
1291
+
1292
+ class ModelRegistryView:
1293
+ """`ctx.model_registry` —— 模型目录的只读面 + provider 注册(pi 的 `ModelRegistry`)。
1294
+
1295
+ 只做扩展真正会用的那几个(列全部/可用、按 provider+id 查、鉴权状态、注册/注销
1296
+ provider),不做完整的解析与补全 —— 那属于宿主内部。
1297
+ """
1298
+
1299
+ def __init__(self, host: Any = None) -> None:
1300
+ self._host = host
1301
+
1302
+ def _call(self, name: str, default: Any, *args: Any, **kwargs: Any) -> Any:
1303
+ if self._host is None:
1304
+ return default
1305
+ fn = getattr(self._host, name, None)
1306
+ if not callable(fn):
1307
+ return default
1308
+ return fn(*args, **kwargs)
1309
+
1310
+ def get_all(self) -> list[Any]:
1311
+ return list(self._call("model_catalog", []))
1312
+
1313
+ def get_available(self) -> list[Any]:
1314
+ return list(self._call("available_models", []) or self.get_all())
1315
+
1316
+ def find(self, provider: str, model_id: str) -> Any | None:
1317
+ for model in self.get_all():
1318
+ if model.provider == provider and model.id == model_id:
1319
+ return model
1320
+ return None
1321
+
1322
+ def has_configured_auth(self, model: Any) -> bool:
1323
+ return bool(self._call("has_configured_auth", False, model))
1324
+
1325
+ def get_provider_display_name(self, provider: str) -> str:
1326
+ return str(self._call("provider_display_name", provider, provider))
1327
+
1328
+ def register_provider(self, name: str, config: dict | None = None) -> None:
1329
+ if self._host is None:
1330
+ raise RuntimeError("宿主没有提供 provider 注册接口:register_provider / registerProvider 不可用")
1331
+ self._host.register_provider(name, dict(config or {}), "extension")
1332
+
1333
+ def unregister_provider(self, name: str) -> None:
1334
+ if self._host is None:
1335
+ raise RuntimeError("宿主没有提供 provider 注销接口:unregister_provider / unregisterProvider 不可用")
1336
+ self._host.unregister_provider(name)
1337
+
1338
+ # ── pi 的驼峰别名 ──
1339
+ def getAll(self) -> list[Any]: # noqa: N802
1340
+ return self.get_all()
1341
+
1342
+ def getAvailable(self) -> list[Any]: # noqa: N802
1343
+ return self.get_available()
1344
+
1345
+ def hasConfiguredAuth(self, model: Any) -> bool: # noqa: N802
1346
+ return self.has_configured_auth(model)
1347
+
1348
+ def getProviderDisplayName(self, provider: str) -> str: # noqa: N802
1349
+ return self.get_provider_display_name(provider)
1350
+
1351
+ def registerProvider(self, name: str, config: dict | None = None) -> None: # noqa: N802
1352
+ self.register_provider(name, config)
1353
+
1354
+ def unregisterProvider(self, name: str) -> None: # noqa: N802
1355
+ self.unregister_provider(name)
1356
+
1357
+
1358
+
1359
+ @dataclass(frozen=True)
1360
+ class ExtensionContext:
1361
+ """`ctx` —— 传给每个 handler 的上下文(见 docs/extensions.md §6)。
1362
+
1363
+ 字段是**只读值**(frozen),但方法(`abort()` / `compact()` / `getContextUsage()` /
1364
+ 命令上下文的那几个)要回宿主去问 —— 所以带一个 `host` 后向引用(鸭子类型;
1365
+ `extensions` 不能 import `runtime`,会成环)。没给 host 时读方法退回确定值、
1366
+ 写方法报错(与 `ctx.ui` 没前端时的口径一致:**不静默**)。
1367
+
1368
+ `notes` 是**故意可变**的:扩展也能往启动提示里加话(它是宿主给前端的唯一提示通道,
1369
+ runtime 自己不打印)。frozen 只管字段重绑定,不管列表内容。
1370
+
1371
+ 命名:qi 的 snake_case 是正式名,pi 的驼峰 (`isProjectTrusted` / `getSystemPrompt` /
1372
+ `thinkingLevel` / `sessionManager` / …) 是别名。
1373
+ """
1374
+
1375
+ cwd: Path
1376
+ #: 当前模型。`ModelView` 是 `str` 子类:既有的 `== "provider/model"` 用法不变,
1377
+ #: pi 的 `ctx.model.id` / `ctx.model.contextWindow` 也成立。
1378
+ model: ModelView | None = None
1379
+ thinking_level: str = "off"
1380
+ signal: AbortSignal | None = None # 协作式中断:扩展做异步**必须**传它,否则 Esc 取消不掉
1381
+ has_ui: bool = False
1382
+ project_trusted: bool = True
1383
+ notes: list[str] = field(default_factory=list)
1384
+ #: `ctx.ui` —— **总是存在**(不是 Optional):没前端时它按 `default` 回答,
1385
+ #: 所以扩展不必到处写 `if ctx.ui is not None`。要靠它做分支就查 `ctx.has_ui`。
1386
+ ui: ExtensionUi = field(default_factory=ExtensionUi)
1387
+ #: `ctx.session_manager` —— 当前会话的只读视图(见 `SessionView`)。回合外 `available` 为 False。
1388
+ session_manager: SessionView = field(default_factory=SessionView)
1389
+ #: 运行模式:`tui` | `rpc` | `json` | `print`(pi 的 `ExtensionMode`)。
1390
+ #: 组件层 UI(`ctx.ui.custom` / widget / footer)只在 `tui` 里有意义 ——
1391
+ #: 扩展该用 `ctx.mode == "tui"` 做门控(pi 的写法)。
1392
+ mode: str = "print"
1393
+ #: 本回合绑定的会话对象(命令上下文要它来切会话)。**不是**公开面 —— 扩展读用
1394
+ #: `ctx.session_manager`。
1395
+ session: Any = field(default=None, repr=False, compare=False)
1396
+ #: `--models` / `enabledModels` 圈定的模型(空 = 不限制)。
1397
+ scoped_models: tuple[str, ...] = ()
1398
+ #: 宿主后向引用(runtime)。读方法缺它时给确定值,写方法报错。
1399
+ host: Any = field(default=None, repr=False, compare=False)
1400
+ #: `ctx.model_registry`(pi 的 `ModelRegistry` 只读面)。
1401
+ model_registry: ModelRegistryView = field(default_factory=ModelRegistryView)
1402
+
1403
+ # ── pi 的驼峰属性别名 ──
1404
+ @property
1405
+ def hasUI(self) -> bool: # noqa: N802
1406
+ return self.has_ui
1407
+
1408
+ @property
1409
+ def thinkingLevel(self) -> str: # noqa: N802
1410
+ return self.thinking_level
1411
+
1412
+ @property
1413
+ def sessionManager(self) -> SessionView: # noqa: N802
1414
+ return self.session_manager
1415
+
1416
+ @property
1417
+ def scopedModels(self) -> tuple[str, ...]: # noqa: N802
1418
+ return self.scoped_models
1419
+
1420
+ @property
1421
+ def modelRegistry(self) -> ModelRegistryView: # noqa: N802
1422
+ return self.model_registry
1423
+
1424
+ # ── 信任 ──
1425
+ def is_project_trusted(self) -> bool:
1426
+ """pi 的 `ctx.isProjectTrusted()`。"""
1427
+ return self.project_trusted
1428
+
1429
+ def isProjectTrusted(self) -> bool: # noqa: N802
1430
+ return self.project_trusted
1431
+
1432
+ # ── 运行控制(pi 的 ExtensionContext 同名方法)──
1433
+ def abort(self) -> None:
1434
+ """中断当前回合(pi 的 `ctx.abort()`)。没有回合在跑时是 no-op。"""
1435
+ if self.signal is not None:
1436
+ self.signal.abort()
1437
+ host_abort = getattr(self.host, "abort", None)
1438
+ if callable(host_abort) and self.signal is None:
1439
+ host_abort()
1440
+
1441
+ def is_idle(self) -> bool:
1442
+ """agent 空闲吗(没在流式)(pi 的 `ctx.isIdle()`)。"""
1443
+ fn = getattr(self.host, "is_idle", None)
1444
+ return bool(fn()) if callable(fn) else True
1445
+
1446
+ def isIdle(self) -> bool: # noqa: N802
1447
+ return self.is_idle()
1448
+
1449
+ def has_pending_messages(self) -> bool:
1450
+ """有没有排队等送达的扩展消息(pi 的 `ctx.hasPendingMessages()`)。"""
1451
+ fn = getattr(self.host, "has_pending_messages", None)
1452
+ return bool(fn()) if callable(fn) else False
1453
+
1454
+ def hasPendingMessages(self) -> bool: # noqa: N802
1455
+ return self.has_pending_messages()
1456
+
1457
+ def shutdown(self) -> None:
1458
+ """优雅退出 qi(pi 的 `ctx.shutdown()`;只在 TUI/CLI 下有意义)。"""
1459
+ fn = getattr(self.host, "shutdown", None)
1460
+ if callable(fn):
1461
+ fn()
1462
+
1463
+ def get_context_usage(self) -> Any:
1464
+ """当前上下文占用(pi 的 `ContextUsage`:`{tokens, contextWindow, percent}`)。"""
1465
+ fn = getattr(self.host, "context_usage", None)
1466
+ return fn() if callable(fn) else None
1467
+
1468
+ def getContextUsage(self) -> Any: # noqa: N802
1469
+ return self.get_context_usage()
1470
+
1471
+ def compact(self, options: dict | None = None) -> None:
1472
+ """触发一次压缩,**不等**它完成(pi 的 `ctx.compact()` 同语义)。"""
1473
+ fn = getattr(self.host, "compact", None)
1474
+ if callable(fn):
1475
+ fn(options)
1476
+
1477
+ def get_system_prompt(self) -> str:
1478
+ """当前生效的 system prompt 全文(pi 的 `ctx.getSystemPrompt()`)。"""
1479
+ fn = getattr(self.host, "current_system_prompt", None)
1480
+ return str(fn()) if callable(fn) else ""
1481
+
1482
+ def getSystemPrompt(self) -> str: # noqa: N802
1483
+ return self.get_system_prompt()
1484
+
1485
+ def get_system_prompt_options(self) -> dict:
1486
+ """造 system prompt 用的结构化选项(pi 的 `ctx.getSystemPromptOptions()`)。"""
1487
+ fn: Any = getattr(self.host, "system_prompt_options", None)
1488
+ if not callable(fn):
1489
+ return {}
1490
+ value = fn()
1491
+ return dict(value) if isinstance(value, dict) else {}
1492
+
1493
+ def getSystemPromptOptions(self) -> dict: # noqa: N802
1494
+ return self.get_system_prompt_options()
1495
+
1496
+ # ── 会话操作(pi 的 ExtensionCommandContext:只在命令/事件处理器里安全)──
1497
+ async def wait_for_idle(self) -> None:
1498
+ fn: Any = getattr(self.host, "wait_for_idle", None)
1499
+ if callable(fn):
1500
+ result: Any = fn()
1501
+ if inspect.isawaitable(result):
1502
+ await result
1503
+
1504
+ async def waitForIdle(self) -> None: # noqa: N802
1505
+ await self.wait_for_idle()
1506
+
1507
+ async def new_session(self, options: dict | None = None) -> dict:
1508
+ """开一个新会话。返回 `{cancelled: bool}`(pi 同形)。"""
1509
+ return await self._session_op("extension_new_session", options)
1510
+
1511
+ async def newSession(self, options: dict | None = None) -> dict: # noqa: N802
1512
+ return await self.new_session(options)
1513
+
1514
+ async def fork(self, entry_id: str, options: dict | None = None) -> dict:
1515
+ return await self._session_op("extension_fork", entry_id, options)
1516
+
1517
+ async def navigate_tree(self, target_id: str, options: dict | None = None) -> dict:
1518
+ return await self._session_op("extension_navigate_tree", target_id, options)
1519
+
1520
+ async def navigateTree(self, target_id: str, options: dict | None = None) -> dict: # noqa: N802
1521
+ return await self.navigate_tree(target_id, options)
1522
+
1523
+ async def switch_session(self, session_path: str, options: dict | None = None) -> dict:
1524
+ return await self._session_op("extension_switch_session", session_path, options)
1525
+
1526
+ async def switchSession(self, session_path: str, options: dict | None = None) -> dict: # noqa: N802
1527
+ return await self.switch_session(session_path, options)
1528
+
1529
+ async def reload(self) -> None:
1530
+ """重载扩展与资源(pi 的 `ctx.reload()`)。"""
1531
+ fn = getattr(self.host, "reload", None)
1532
+ if callable(fn):
1533
+ result = fn()
1534
+ if inspect.isawaitable(result):
1535
+ await result
1536
+
1537
+ async def _session_op(self, name: str, *args: Any) -> dict:
1538
+ fn: Any = getattr(self.host, name, None)
1539
+ if not callable(fn):
1540
+ raise RuntimeError(f"宿主没有提供会话操作:{name} 不可用(要宿主拥有会话控制权)")
1541
+ result = fn(self, *args)
1542
+ if inspect.isawaitable(result):
1543
+ return await result
1544
+ return dict(result) if isinstance(result, dict) else {}
1545
+
1546
+
1547
+ @dataclass
1548
+ class EmitResult:
1549
+ """一次派发的结果。
1550
+
1551
+ `payload` 是链结束后那一个 dict(patch 链的最终值);
1552
+ `returns` 保留每个 handler 的原始返回值(诊断);
1553
+ `errors` 是 `(来源, 异常)` —— 有内容不代表派发失败,只代表那些 handler 没生效。
1554
+ """
1555
+
1556
+ payload: dict
1557
+ returns: list[Any] = field(default_factory=list)
1558
+ errors: list[tuple[str, Exception]] = field(default_factory=list)
1559
+ result: dict | None = None # `emit_until` 命中裁决时,那个 handler 的返回
1560
+ stopped_by: str | None = None # 谁终止了链(None = 全跑完)
1561
+
1562
+ @property
1563
+ def ok(self) -> bool:
1564
+ return not self.errors
1565
+
1566
+
1567
+ class ExtensionEvents:
1568
+ """`api.events` —— **扩展之间**的消息频道(不是宿主事件)。
1569
+
1570
+ 与 `api.on`(宿主事件)的分工:宿主事件是“qi 在通知你”,有链式返回值与裁决语义;
1571
+ 这里只是“扩展之间说一声”。分开的意义在于两者**语义完全不同** —— 混成一件事的话,
1572
+ 迟早有人给宿主事件 emit 一条自定义消息(然后奇怪为什么没人理),或者拿 peer 消息
1573
+ 去拦工具调用(拦不住 —— 拦截必须走 `tool_call`)。
1574
+
1575
+ `emit` **不等待**:pi 的 `pi.events.emit` 也是同步的。要拿结果就用宿主事件 +
1576
+ `sendMessage`,而不是把这里改成 async(那会让所有调用点都跟着改)。
1577
+ """
1578
+
1579
+ def __init__(self, bus: "ExtensionBus", source: str = "") -> None:
1580
+ self._bus = bus
1581
+ self._source = source
1582
+
1583
+ def on(self, name: str, handler: Callable[[Any], Any]) -> Callable[[], None]:
1584
+ """订阅一条 peer 消息。**返回退订函数**(pi 的 `EventBus.on` 同形)。"""
1585
+ self._bus.on_message(name, handler, source=self._source)
1586
+
1587
+ def _unsubscribe() -> None:
1588
+ self._bus.remove_message(name, handler)
1589
+
1590
+ return _unsubscribe
1591
+
1592
+ def emit(self, name: str, data: Any = None) -> None:
1593
+ self._bus.send_message(name, data, source=self._source)
1594
+
1595
+ @property
1596
+ def names(self) -> list[str]:
1597
+ """已被订阅的 peer 消息名(诊断用)。"""
1598
+ return self._bus.message_names
1599
+
1600
+
1601
+ class ExtensionBus:
1602
+ """扩展事件总线:按装载顺序链式派发(§4)。
1603
+
1604
+ 同一个对象上还挂着**扩展之间**的消息频道(`api.events`)。共用对象是刻意的:
1605
+ 总线本来就是“所有扩展共享的那一个东西”(而且它是必填参数),另起一个对象
1606
+ 迟早会出现“两个扩展各拿一个总线、消息静默发不到”.
1607
+ """
1608
+
1609
+ def __init__(self, notes: list[str] | None = None) -> None:
1610
+ # event -> [(来源, handler)]。来源 = 扩展名(诊断时能指到具体哪个扩展)
1611
+ self._handlers: dict[str, list[tuple[str, ExtensionHandler]]] = {}
1612
+ #: peer 消息频道:`name -> [(来源, handler)]`。与上面的 `_handlers`(宿主事件)
1613
+ #: 是**两张分开的表** —— 所以同名的宿主事件与 peer 消息互不干扰。
1614
+ self._messages: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {}
1615
+ #: handler 抛异常的去处(宿主传自己的 notes;没传就自己攒着,可从 `problems` 读)
1616
+ self._notes = notes if notes is not None else []
1617
+
1618
+ # ── 注册 ──
1619
+ def on(self, event: str, handler: ExtensionHandler, *, source: str = "") -> None:
1620
+ """订阅事件。`source` 缺省用 handler 的模块名(库内调用方应显式给扩展名)。"""
1621
+ self._handlers.setdefault(event, []).append((source or _handler_name(handler), handler))
1622
+
1623
+ # ── 扩展之间(peer messaging)──
1624
+ def on_message(self, name: str, handler: Callable[[Any], Any], *,
1625
+ source: str = "") -> None:
1626
+ """订阅一条 peer 消息。handler 只收 `data`(没有 ctx —— peer 消息不该需要宿主上下文)。"""
1627
+ self._messages.setdefault(name, []).append(
1628
+ (source or _handler_name(handler), handler))
1629
+
1630
+ def remove_message(self, name: str, handler: Callable[[Any], Any]) -> bool:
1631
+ """退订一条 peer 消息(按 handler 身份匹配)。返回是否真的移除了。"""
1632
+ listeners = self._messages.get(name)
1633
+ if not listeners:
1634
+ return False
1635
+ for index, (_, existing) in enumerate(listeners):
1636
+ if existing is handler:
1637
+ listeners.pop(index)
1638
+ return True
1639
+ return False
1640
+
1641
+ def send_message(self, name: str, data: Any = None, *, source: str = "") -> None:
1642
+ """发一条 peer 消息:同步 handler 立即调,async handler 排成后台任务。
1643
+
1644
+ **不等待**、**没有返回值**。单个 handler 抛异常 → 记进宿主的 notes 并继续
1645
+ (与宿主事件同一条规则:一个扩展坏掉不拖垮别的),
1646
+ """
1647
+ for listener, handler in list(self._messages.get(name, ())):
1648
+ try:
1649
+ result = handler(data)
1650
+ except Exception as exc: # noqa: BLE001 第三方代码
1651
+ self._notes.append(f"扩展 {listener} 的 events.on({name!r}) 处理失败: {exc}")
1652
+ continue
1653
+ if inspect.isawaitable(result):
1654
+ self._schedule(result, listener, name)
1655
+
1656
+ def _schedule(self, awaitable: Any, listener: str, name: str) -> None:
1657
+ """把 async handler 排成后台任务。没在事件循环里就丢掉(与 `_emit_notice` 同一条取舍)。"""
1658
+ async def _run() -> None:
1659
+ try:
1660
+ await awaitable
1661
+ except Exception as exc: # noqa: BLE001 第三方代码
1662
+ self._notes.append(f"扩展 {listener} 的 events.on({name!r}) 处理失败: {exc}")
1663
+
1664
+ try:
1665
+ asyncio.get_running_loop().create_task(_run())
1666
+ except RuntimeError:
1667
+ return
1668
+
1669
+ @property
1670
+ def message_names(self) -> list[str]:
1671
+ return sorted(self._messages)
1672
+
1673
+ @property
1674
+ def problems(self) -> list[str]:
1675
+ """handler 异常的记录(宿主没传 notes 时用这个读)。"""
1676
+ return list(self._notes)
1677
+
1678
+ @property
1679
+ def events(self) -> list[str]:
1680
+ """已有人订阅的事件名(升序)。"""
1681
+ return sorted(self._handlers)
1682
+
1683
+ def handler_count(self, event: str) -> int:
1684
+ return len(self._handlers.get(event, ()))
1685
+
1686
+ def has(self, event: str) -> bool:
1687
+ """有人订阅这个事件吗?—— 宿主用它做**逐事件的开销护栏**:
1688
+
1689
+ 没有订阅就不构造 payload、不建 `EmitResult` 直接跳过。所以“零扩展时零开销”
1690
+ 不是承诺,而是写在各派发点上的一行判断。
1691
+ """
1692
+ return bool(self._handlers.get(event))
1693
+
1694
+ @property
1695
+ def is_empty(self) -> bool:
1696
+ """一个 handler 都没有 —— 宿主可以据此跳过整条派发路径(零扩展时的常见情形)。"""
1697
+ return not self._handlers
1698
+
1699
+ # ── 派发 ──
1700
+ async def emit(self, event: str, payload: dict | None = None, *,
1701
+ ctx: ExtensionContext) -> EmitResult:
1702
+ """通知 / patch 链:所有 handler 都跑,返回值里的 dict 被并进 payload。"""
1703
+ return await self._dispatch(event, payload, ctx, stop_keys=None,
1704
+ stop_values=None, on_error_result=None)
1705
+
1706
+ async def emit_until(self, event: str, payload: dict | None = None, *,
1707
+ ctx: ExtensionContext,
1708
+ stop_keys: tuple[str, ...] = (),
1709
+ stop_values: dict[str, tuple[str, ...]] | None = None,
1710
+ on_error_result: dict | None = None) -> EmitResult:
1711
+ """裁决型派发:**第一个**命中裁决的 handler 胜,链立即停(§3.1)。
1712
+
1713
+ 裁决有**两种形状**,都是 pi 实际在用的,所以两种都要支持 ——
1714
+ 差别是真实的,不是风格:
1715
+
1716
+ * `stop_keys` —— 键的**真值**即裁决:tool_call 的 `{{"block": True}}`。
1717
+ `{{block: False}}` 不算(不能用一个假值把后面的闸门短路掉)。
1718
+ * `stop_values` —— 键的**取值落在集合里**即裁决:input 的 `{{"action": "handled"}}`。
1719
+ 这里必须按值判:同一个 `action` 键下 `transform` 是**链式**的(要接着往下改),
1720
+ 把 `action` 当键真值判会让多级改写变成"第一级就定案"。
1721
+
1722
+ `on_error_result` 是 fail-safe:handler 抛异常时用它当裁决(不给 = 只记错继续链)。
1723
+ """
1724
+ return await self._dispatch(event, payload, ctx, stop_keys=stop_keys,
1725
+ stop_values=stop_values,
1726
+ on_error_result=on_error_result)
1727
+
1728
+ async def _dispatch(self, event: str, payload: dict | None,
1729
+ ctx: ExtensionContext, *,
1730
+ stop_keys: tuple[str, ...] | None,
1731
+ stop_values: dict[str, tuple[str, ...]] | None,
1732
+ on_error_result: dict | None) -> EmitResult:
1733
+ # payload 始终是**同一个** dict:handler 原地改就对后续可见(patch 链规则 2)
1734
+ base: dict = dict(payload or {})
1735
+ # pi 兼容层:每个事件都带 `type`(pi 的判别字段),并有驼峰键别名。
1736
+ # 放在**派发之前**做一次,所以 handler 从第一个开始就两边都读得到。
1737
+ base.setdefault("type", event)
1738
+ _alias_pi_keys(base)
1739
+ out = EmitResult(payload=base)
1740
+ # 快照遍历(规则 1):handler 里再 on() 不影响本次派发
1741
+ for source, handler in list(self._handlers.get(event, ())):
1742
+ try:
1743
+ returned = handler(base, ctx)
1744
+ if inspect.isawaitable(returned):
1745
+ returned = await returned
1746
+ except Exception as exc: # noqa: BLE001 规则 3:一个 handler 坏不拖垮整条链
1747
+ out.errors.append((source, exc))
1748
+ if on_error_result is not None:
1749
+ # fail-safe:闸门崩了就拦(status quo 偏向"不执行")
1750
+ out.result = dict(on_error_result)
1751
+ out.stopped_by = source
1752
+ break
1753
+ continue
1754
+ out.returns.append(returned)
1755
+ if isinstance(returned, dict):
1756
+ base.update(returned)
1757
+ # handler 新加/改过的键也要补别名(否则“我在 handler 里设了 tool_name,
1758
+ # 后面的 handler 读 toolName 读不到”)
1759
+ _alias_pi_keys(base)
1760
+ if _is_verdict(returned, stop_keys, stop_values):
1761
+ out.result = dict(returned)
1762
+ out.stopped_by = source
1763
+ break
1764
+ # 非 dict 返回值:忽略(不报错)—— 扩展可能比宿主新
1765
+ return out
1766
+
1767
+
1768
+ @dataclass
1769
+ class ExtensionApi:
1770
+ """传给扩展 `register(api)` 的接口。
1771
+
1772
+ 刻意**不 import** `ToolCatalog`(只鸭子类型用 `register`):`registry` 反向导入本模块
1773
+ 来构造这个对象,直接 import 会成环。
1774
+ """
1775
+
1776
+ catalog: Any
1777
+ bus: ExtensionBus
1778
+ _config_kinds: set[str] = field(default_factory=set)
1779
+ _types: dict[str, set[str]] = field(default_factory=dict)
1780
+ _name: str = ""
1781
+ #: 来源信息(`source_info` 的四个键;由发现阶段告诉 api 它是从哪来的)
1782
+ _path: str = ""
1783
+ _scope: str = "temporary" # user | project | temporary
1784
+ _origin: str = "top-level" # top-level | package
1785
+ #: 宿主(runtime)提供的工具集读写面 —— 鸭子类型,只要有两个方法:
1786
+ #: `tool_names() -> list[str]`(本回合实际启用的工具名)
1787
+ #: `set_tool_names(names) -> None`(覆盖,对后续回合生效)
1788
+ #: 以及会话写口:`append_extension_entry(custom_type, data, source) -> None`。
1789
+ #: 与消息队列:`queue_extension_message(text, deliver_as, source, kind) -> None`。
1790
+ #: 用鸭子类型而不是 Protocol:`extensions` 不能 import runtime(成环),而协议
1791
+ #: 在这里只起文档作用 —— 类型检查器验不到实现方,不如把契约写在这里。
1792
+ #: 没给 host 时 `getActiveTools` 退回“catalog 里的全部”,`setActiveTools` 报错。
1793
+ _host: Any = None
1794
+ #: 命令与快捷键的登记处(由发现阶段注入);没给则 `registerCommand` 报错。
1795
+ _commands: CommandRegistry | None = None
1796
+ #: CLI 旗标的登记处(同上);没给则 `registerFlag` 报错。
1797
+ _flags: FlagRegistry | None = None
1798
+ #: CLI 子命令的登记处(同上);没给则 `registerCliCommand` 报错。
1799
+ _cli_commands: CliCommandRegistry | None = None
1800
+ #: 能力交接的汇合点(宿主侧,发现阶段注入 —— 类型写 `Any` 是因为 `CapabilityRegistry`
1801
+ #: 住在 registry.py,而它反过来 import 本模块,写成具体类型会成环)。
1802
+ #: 与 `_host` / `catalog` 一样是鸭子类型:只需要 `add_resolver` / `resolve_tools`。
1803
+ _capabilities: Any = None
1804
+ #: 渲染回调的登记处(pi 的 `registerMessageRenderer` / `registerEntryRenderer` /
1805
+ #: `registerMarkdownTransformer`)。没给则那三个方法记一条 note(不静默)。
1806
+ _renderers: Any = None
1807
+
1808
+ # ── 工具 ──
1809
+ def register_tool(self, tool: Tool | dict) -> None:
1810
+ """注册工具(进 ToolCatalog),并盖上本扩展的来源。
1811
+
1812
+ **装载后也能调**(事件里、命令里):catalog 是活的对象,而 `tools: ["*"]` 的
1813
+ agent 每回合**当场重算**工具集,所以新工具下一轮就能调,不需要 `/reload`。
1814
+
1815
+ 收 `Tool`,也收 pi 形状的 dict(`{name, label, description, promptSnippet,
1816
+ promptGuidelines, parameters, execute}`;snake_case 键也认)。
1817
+
1818
+ pi 的 `constrainedSampling` **收下但用不了**:逐工具受限采样要 provider 配合
1819
+ (JSON schema / grammar),litellm 没有可移植的对应物 —— 所以这里记一条 note
1820
+ 说明它被忽略。收而不报最坏:扩展以为参数被约束住了。
1821
+ """
1822
+ tool_obj = _coerce_tool(tool)
1823
+ if tool_obj.constrained_sampling:
1824
+ _note(self._host,
1825
+ f"工具 {tool_obj.name} 声明了 constrainedSampling —— qi 不支持逐工具受限采样"
1826
+ "(litellm 无可移植对应物),已忽略")
1827
+ register_tool(self.catalog, tool_obj, source=self._name,
1828
+ path=self._path, scope=self._scope, origin=self._origin)
1829
+
1830
+ def add_tool(self, tool: Tool) -> None:
1831
+ """v1 旧名;`registerTool` 是正式名(对齐 pi)。"""
1832
+ self.register_tool(tool)
1833
+
1834
+ def get_all_tools(self) -> list[dict]: # noqa: N802
1835
+ """所有**已注册**工具的元数据(含 `source_info`)。
1836
+
1837
+ 纯 catalog 查询,不需要宿主 —— 所以装载阶段就能用(例如扩展想知道
1838
+ 自己是不是唯一的 `grep` 提供者)。
1839
+ """
1840
+ return [tool_info(t) for t in self.catalog.all()]
1841
+
1842
+ def get_active_tools(self) -> list[str]: # noqa: N802
1843
+ """本回合实际启用的工具名。宿主没给工具面时退回“catalog 里的全部”。"""
1844
+ if self._host is None:
1845
+ return sorted(self.catalog.names)
1846
+ return list(self._host.tool_names())
1847
+
1848
+ def set_active_tools(self, names: Sequence[str]) -> None: # noqa: N802
1849
+ """改运行时的工具集(plan-mode / 只读角色那种需求)。
1850
+
1851
+ **未知名字被过滤**而不是报错:pi 允许先把名字放进集合、工具随后才动态注册。
1852
+ 但静默丢弃也不行(打错一个字等于悄悄改了权限),所以过虑掉的会写进宿主的
1853
+ `notes` —— 看得见。
1854
+ """
1855
+ if self._host is None:
1856
+ raise RuntimeError("宿主没有提供工具集接口:set_active_tools / setActiveTools 不可用")
1857
+ wanted = [str(n) for n in names]
1858
+ unknown = sorted({n for n in wanted if n not in self.catalog.names})
1859
+ if unknown:
1860
+ _note(self._host, f"setActiveTools 收到未注册的工具名(已忽略): {unknown}")
1861
+ self._host.set_tool_names(wanted)
1862
+
1863
+ async def exec(self, command: str, args: Sequence[str] | None = None, *,
1864
+ cwd: str | Path | None = None, timeout: float | None = None,
1865
+ signal: AbortSignal | None = None) -> ExecResult:
1866
+ """起一个不经 shell 的子进程(pi 的 `pi.exec`)。
1867
+
1868
+ 异步场景**必须**把 `ctx.signal` 传进来,否则用户按 Esc 取消不掉它。
1869
+ """
1870
+ return await exec_command(command, args, cwd=cwd, timeout=timeout, signal=signal)
1871
+
1872
+ # ── 命令与快捷键 ──
1873
+ def register_command(self, name: str, handler: CommandHandler | dict | None = None, *,
1874
+ description: str = "",
1875
+ get_argument_completions: Any = None) -> None:
1876
+ """注册一条斜杠命令(pi 的 `registerCommand`)。handler 收 `(args, ctx)`。
1877
+
1878
+ 两种写法都收:
1879
+ * qi 形状:`register_command("deploy", handler, description="…")`
1880
+ * pi 形状:`register_command("deploy", {"handler": h, "description": "…",
1881
+ "getArgumentCompletions": fn})`
1882
+
1883
+ 重名不覆盖:两条都留着并变成 `name:1` / `name:2`(见 `CommandRegistry`)。
1884
+ """
1885
+ options = handler if isinstance(handler, dict) else None
1886
+ if options is not None:
1887
+ handler = options.get("handler")
1888
+ description = description or str(options.get("description") or "")
1889
+ get_argument_completions = (get_argument_completions
1890
+ or options.get("getArgumentCompletions")
1891
+ or options.get("get_argument_completions"))
1892
+ if not callable(handler):
1893
+ raise TypeError("register_command 需要 handler(位置参数或 options 里的 'handler')")
1894
+ if self._commands is None:
1895
+ raise RuntimeError("宿主没有提供命令登记处:register_command / registerCommand 不可用")
1896
+ self._commands.add_command(name.strip().lstrip("/"), handler,
1897
+ description=description, source=self._name,
1898
+ get_argument_completions=get_argument_completions)
1899
+
1900
+ def register_shortcut(self, key: str, handler: ShortcutHandler | dict | None = None, *,
1901
+ description: str = "") -> None:
1902
+ """注册一个快捷键(pi 的 `registerShortcut`)。key 用 textual 的写法。
1903
+
1904
+ 兼容 pi 的 options 对象写法(`{"handler": h, "description": "…"}`)。
1905
+ """
1906
+ options = handler if isinstance(handler, dict) else None
1907
+ if options is not None:
1908
+ handler = options.get("handler")
1909
+ description = description or str(options.get("description") or "")
1910
+ if not callable(handler):
1911
+ raise TypeError("register_shortcut 需要 handler(位置参数或 options 里的 'handler')")
1912
+ if self._commands is None:
1913
+ raise RuntimeError("宿主没有提供命令登记处:register_shortcut / registerShortcut 不可用")
1914
+ self._commands.add_shortcut(key, handler, description=description,
1915
+ source=self._name)
1916
+
1917
+ def get_commands(self) -> list[dict]:
1918
+ """当前可输入的命令清单(给自动补全 / 帮助用)。
1919
+
1920
+ 元素形状照 pi 的 `SlashCommandInfo`(name/description/source/sourceInfo),
1921
+ 另带 `has_argument_completions`(qi 自己的诊断字段)。
1922
+ """
1923
+ if self._commands is None:
1924
+ return []
1925
+ return [{"name": c.invocable, "description": c.description, "source": c.source,
1926
+ "source_info": {"source": c.source},
1927
+ "sourceInfo": {"source": c.source},
1928
+ "has_argument_completions": callable(c.get_argument_completions)}
1929
+ for c in self._commands.all()]
1930
+
1931
+ async def run_agent(self, spec: Any, task: str, *, abort: AbortSignal | None = None,
1932
+ on_event: Any = None) -> str: # noqa: N802
1933
+ """在宿主内起一个**受管的子运行**:独立上下文、自己的工具集与模型。
1934
+
1935
+ 这是 qi-agents(以及任何“把一个任务交给另一个角色”的需求)的基石。
1936
+ `spec` 是 dict:`system_prompt`(必填)/ `tools`(缺省**继承父**的当前集合)/
1937
+ `model`(缺省继承父)/ `name`。
1938
+
1939
+ 与 `sendMessage` 的区别:那个是“往当前对话里插一句话”,这个是“另起一个**不碰
1940
+ 会话**的运行” —— 子运行不落盘、不分派,但扩展事件照常派发(所以闸门对它也生效)。
1941
+
1942
+ `on_event` 给进度用(子运行的工具调用能实时上报给父界面)。
1943
+ """
1944
+ if self._host is None or not callable(getattr(self._host, "run_agent", None)):
1945
+ raise RuntimeError("宿主没有提供子运行接口:run_agent / runAgent 不可用")
1946
+ return await self._host.run_agent(spec, task, abort=abort, on_event=on_event)
1947
+
1948
+ # ── CLI 旗标 ──
1949
+ def register_flag(self, name: str, options: dict | None = None, *,
1950
+ type: str = "boolean", default: Any = False,
1951
+ description: str = "") -> None:
1952
+ """声明一个 CLI 旗标(pi 的 `registerFlag`)。
1953
+
1954
+ **值不走 `--plan` 这种短形式**,而是 `qi --ext plan=true` —— 原因见 §11.8:
1955
+ typer 的选项表是静态的,为动态旗标放宽 `ignore_unknown_options` 会把用户
1956
+ 打错的选项变成一句 prompt。`--ext` 显式、可 grep、不可能和笔误混淆。
1957
+
1958
+ 兼容 pi 的 options 对象写法(`register_flag("plan", {"type": "boolean"})`)。
1959
+ """
1960
+ if isinstance(options, dict):
1961
+ type = str(options.get("type") or type)
1962
+ default = options.get("default", default)
1963
+ description = description or str(options.get("description") or "")
1964
+ if self._flags is None:
1965
+ raise RuntimeError("宿主没有提供旗标登记处:register_flag / registerFlag 不可用")
1966
+ self._flags.add(name, type=type, default=default,
1967
+ description=description, source=self._name)
1968
+
1969
+ def get_flag(self, name: str) -> Any: # noqa: N802
1970
+ """读旗标当前值(`--ext` 给的优先,否则声明的 default)。"""
1971
+ if self._flags is None:
1972
+ return None
1973
+ return self._flags.value(name)
1974
+
1975
+ # ── CLI 子命令 ──
1976
+ def register_cli_command(self, name: str, handler: CliHandler, *, # noqa: N802
1977
+ description: str = "") -> None:
1978
+ """注册一个 CLI 子命令:`qi <name> [参数…]`。
1979
+
1980
+ `handler(argv)` 收到**命令名之后的原始 argv**(如 `["--port", "30142"]`),返回
1981
+ 退出码或 None。**故意不接 typer/click 的解析结果**:选项表是静态的,动态改它会把
1982
+ 用户打错的选项变成别的东西(E19 实测),所以各扩展自己解析自己的参数。
1983
+
1984
+ 宿主会在 typer 之前认出这个名字(`cli._dispatch_extension_command`)—— 扩展不必知道
1985
+ typer 的存在。**与 core 子命令同名时 core 优先**;两个扩展撞名则是错误(CLI 名字是用户
1986
+ 敲的第一个词,`qi web:1` 这种没法用,所以不编号)。
1987
+ """
1988
+ if self._cli_commands is None:
1989
+ # **没登记处就是 no-op**:CLI 子命令只在"由 CLI 入口装载"的那个宿主里能被派发,
1990
+ # 而宿主有很多种(runtime / doctor / 测试里的轻量发现 / 第三方)。让这里报错等于
1991
+ # "凡是注册 CLI 子命令的扩展,在任何不关心 CLI 的宿主里都装不上" —— 而 qi-web 就是
1992
+ # 这种扩展。**重名仍然报错**(那是真冲突),只是"这儿的宿主不收"不算错。
1993
+ return
1994
+ if not self._cli_commands.add_command(name, handler, description=description,
1995
+ source=self._name):
1996
+ raise RuntimeError(f"CLI 子命令 `{name}` 已被占用(改个名字,或确认没有装两个))")
1997
+
1998
+ # ── 扩展之间 ──
1999
+ @property
2000
+ def events(self) -> ExtensionEvents:
2001
+ """`api.events` —— 扩展之间的消息频道(见 `ExtensionEvents`)。"""
2002
+ return ExtensionEvents(self.bus, source=self._name)
2003
+
2004
+ # ── provider ──
2005
+ def register_provider(self, name: str, config: dict | None = None) -> None: # noqa: N802
2006
+ """动态注册/覆盖一个 provider(代理、自定义端点、团队模型配置)。
2007
+
2008
+ **只改内存,不写 `models.json`** —— 注册的 provider 活在这个进程里。
2009
+ pi 允许扩展持久化目录元数据(带 generation 校验),那是另一套机制,qi 没做。
2010
+
2011
+ 覆盖同名 provider 是允许的(代理正是这个用途),但会记一条 note:
2012
+ “我明明配了 models.json,却被别人改了” 很难查。
2013
+ """
2014
+ if self._host is None or not callable(getattr(self._host, "register_provider", None)):
2015
+ raise RuntimeError("宿主没有提供 provider 注册接口:register_provider / registerProvider 不可用")
2016
+ self._host.register_provider(name, dict(config or {}), self._name)
2017
+
2018
+ def unregister_provider(self, name: str) -> None:
2019
+ """注销一个先前注册的 provider(pi 的 `unregisterProvider`)。
2020
+
2021
+ 找不到就无操作(pi 同义)—— “本来就没了”与“刚被删了”在调用点无法区分,
2022
+ 报错只会让清理逻辑多一层无谓的异常处理。
2023
+ """
2024
+ host = getattr(self._host, "unregister_provider", None)
2025
+ if not callable(host):
2026
+ raise RuntimeError("宿主没有提供 provider 注销接口:unregister_provider / unregisterProvider 不可用")
2027
+ host(name)
2028
+
2029
+ # ── 会话 ──
2030
+ def append_entry(self, custom_type: str, data: dict | None = None) -> None: # noqa: N802
2031
+ """落一条扩展自定义 entry(进会话文件,**不进 LLM 上下文**)。
2032
+
2033
+ 这才是持久化扩展状态的正确位置:它不会污染对话上下文,但刷新/重开后还在
2034
+ (`ctx.session_manager.custom_entries(name)` 读回来)。
2035
+
2036
+ 回合外没有活动会话时**报错** —— 静默丢掉意味着“我存了但重启后没了”。
2037
+ """
2038
+ if self._host is None or not callable(
2039
+ getattr(self._host, "append_extension_entry", None)):
2040
+ raise RuntimeError("宿主没有提供会话写口:append_entry / appendEntry 不可用")
2041
+ self._host.append_extension_entry(custom_type, dict(data or {}), self._name)
2042
+
2043
+ # ── 会话名与 label(pi 的 setSessionName / getSessionName / setLabel)──
2044
+ def set_session_name(self, name: str) -> None:
2045
+ host = getattr(self._host, "set_session_name", None)
2046
+ if not callable(host):
2047
+ raise RuntimeError("宿主没有提供会话改名接口:set_session_name / setSessionName 不可用")
2048
+ host(name)
2049
+
2050
+ def get_session_name(self) -> str | None:
2051
+ host = getattr(self._host, "get_session_name", None)
2052
+ if not callable(host):
2053
+ return None
2054
+ value = host()
2055
+ return None if value is None else str(value)
2056
+
2057
+ def set_label(self, entry_id: str, label: str | None) -> None:
2058
+ """给某条 entry 打/清 label(label 是用户可见的书签,pi 同义)。"""
2059
+ host = getattr(self._host, "set_entry_label", None)
2060
+ if not callable(host):
2061
+ raise RuntimeError("宿主没有提供 label 写口:set_label / setLabel 不可用")
2062
+ host(entry_id, label)
2063
+
2064
+ # ── 模型与思考级别(pi 的 setModel / get-setThinkingLevel)──
2065
+ def set_model(self, model: Any) -> bool:
2066
+ """切本会话的模型(不动配置默认值)。接受 `"provider/model"` / `ModelView` /
2067
+ `{"provider": …, "id"/"model": …}`。返回是否成功(pi 同形)。"""
2068
+ host = getattr(self._host, "set_extension_model", None)
2069
+ if not callable(host):
2070
+ raise RuntimeError("宿主没有提供模型切换接口:set_model / setModel 不可用")
2071
+ return bool(host(model))
2072
+
2073
+ def get_thinking_level(self) -> str:
2074
+ value = getattr(self._host, "thinking_level", None)
2075
+ return str(value) if value else "off"
2076
+
2077
+ def set_thinking_level(self, level: str) -> None:
2078
+ host = getattr(self._host, "set_thinking_level", None)
2079
+ if not callable(host):
2080
+ raise RuntimeError("宿主没有提供思考级别接口:set_thinking_level / setThinkingLevel 不可用")
2081
+ host(level, source="extension")
2082
+
2083
+ def send_message(self, message: str | dict, *, deliver_as: str | None = None,
2084
+ trigger_turn: bool = False) -> None:
2085
+ """往当前对话插一条消息(**进** LLM 上下文;与 `append_entry` 相反)。
2086
+
2087
+ `deliver_as` 决定**什么时候**送达(照搬 pi 的三档,pi 的驼峰写法也认):
2088
+
2089
+ * `steer`(默认)—— 本轮的**下一次 LLM 调用**之前(即当前这轮工具跑完之后)。
2090
+ * `follow_up`(`followUp`)—— 等 agent **本该收工**时才送:有排队消息就不收工。
2091
+ * `next_turn`(`nextTurn`)—— 不打断本轮,留到**下一次用户输入**。
2092
+
2093
+ 收字符串,也收 pi 形状的字典(`{customType, content, display, details}`)。
2094
+ `trigger_turn=True` = 空闲时也开一轮(由前端在命令处理完后领取)。
2095
+ """
2096
+ custom_type = display = details = None
2097
+ if isinstance(message, str):
2098
+ text = message
2099
+ else:
2100
+ text = str(message.get("content") or "")
2101
+ custom_type = message.get("customType") or message.get("custom_type")
2102
+ display = message.get("display")
2103
+ details = message.get("details")
2104
+ self._queue_message(text, deliver_as or "steer", "sendMessage",
2105
+ custom_type=custom_type, display=display, details=details,
2106
+ trigger_turn=trigger_turn)
2107
+
2108
+ def send_user_message(self, content: str, *, deliver_as: str | None = None,
2109
+ trigger_turn: bool = True,
2110
+ expand_prompt_templates: bool = False) -> None:
2111
+ """插一条**用户**消息(pi 的 `sendUserMessage`)。
2112
+
2113
+ `trigger_turn` 缺省 **True**(pi 同义:空闲时会开一轮)。qi 的做法是把“要开一轮”
2114
+ 记在宿主身上,由前端在本轮/命令处理完后领取(`take_turn_request()`)——
2115
+ 因为“在 handler 里嵌套跑一轮”在流式架构里是另一件事(§11.9)。
2116
+
2117
+ `expand_prompt_templates=True` 时交给宿主的展开器(命令 / 技能 / 提示词模板);
2118
+ 宿主不支持则记一条 note 后**原样**入队(不静默假装展开了)。
2119
+ """
2120
+ text = content
2121
+ if expand_prompt_templates:
2122
+ expand = getattr(self._host, "expand_prompt_text", None)
2123
+ if callable(expand):
2124
+ text = str(expand(text))
2125
+ else:
2126
+ _note(self._host, "宿主不支持 expand_prompt_templates(...):原样入队")
2127
+ self._queue_message(text, deliver_as or "steer", "sendUserMessage",
2128
+ trigger_turn=trigger_turn)
2129
+
2130
+ def _queue_message(self, text: str, deliver_as: str, kind: str, *, custom_type: Any = None,
2131
+ display: Any = None, details: Any = None,
2132
+ trigger_turn: bool = False) -> None:
2133
+ body = (text or "").strip()
2134
+ if not body:
2135
+ return # 空白不入队(与 message 注入一致)
2136
+ normalized = _DELIVER_AS.get(str(deliver_as))
2137
+ if normalized is None:
2138
+ raise ValueError(
2139
+ f"deliver_as 只能是 steer / follow_up / next_turn(pi 写法 followUp / "
2140
+ f"nextTurn 也认),收到 {deliver_as!r}")
2141
+ if self._host is None or not callable(
2142
+ getattr(self._host, "queue_extension_message", None)):
2143
+ raise RuntimeError("宿主没有提供消息队列:send_message / sendMessage 不可用")
2144
+ self._host.queue_extension_message(body, normalized, self._name, kind,
2145
+ custom_type=custom_type, display=display,
2146
+ details=details, trigger_turn=bool(trigger_turn))
2147
+
2148
+ # ── 渲染(TUI-only,见 §5.1)──
2149
+ def register_message_renderer(self, custom_type: str, renderer: Any) -> None:
2150
+ """给某个 custom 消息类型注册渲染器(pi 的 `registerMessageRenderer`)。"""
2151
+ if self._renderers is None:
2152
+ _note(self._host, "宿主没有渲染登记处:register_message_renderer / registerMessageRenderer 未生效")
2153
+ return
2154
+ self._renderers.add_message(custom_type, renderer, source=self._name)
2155
+
2156
+ def register_entry_renderer(self, custom_type: str, renderer: Any) -> None:
2157
+ """给某个 custom entry 类型注册渲染器(pi 的 `registerEntryRenderer`)。"""
2158
+ if self._renderers is None:
2159
+ _note(self._host, "宿主没有渲染登记处:register_entry_renderer / registerEntryRenderer 未生效")
2160
+ return
2161
+ self._renderers.add_entry(custom_type, renderer, source=self._name)
2162
+
2163
+ def register_markdown_transformer(self, transformer: Any) -> None:
2164
+ """注册一个 markdown 渲染前的改写器(pi 的 `registerMarkdownTransformer`)。"""
2165
+ if self._renderers is None:
2166
+ _note(self._host, "宿主没有渲染登记处:register_markdown_transformer / registerMarkdownTransformer 未生效")
2167
+ return
2168
+ self._renderers.add_markdown(transformer, source=self._name)
2169
+
2170
+ # ── 事件 ──
2171
+ def on(self, event: str, handler: ExtensionHandler) -> None:
2172
+ """订阅事件;来源自动带上本扩展名(诊断时能指到是谁)。"""
2173
+ self.bus.on(event, handler, source=self._name)
2174
+
2175
+ # ── 消费型配置(v1 机制,原样继承)──
2176
+ def provides_config(self, kind: str, types: list[str] | None = None) -> None:
2177
+ """声明消费的 agent 配置种类;types 为该种类支持的 type 值(如数据源 mysql/…)。"""
2178
+ self._config_kinds.add(kind)
2179
+ if types:
2180
+ self._types.setdefault(kind, set()).update(types)
2181
+
2182
+ @property
2183
+ def config_kinds(self) -> set[str]:
2184
+ return self._config_kinds
2185
+
2186
+ # ── 能力交接(提供方声明 + 消费方取用,P-E5 ② / E20)──
2187
+ def register_resolver(self, kind: str, resolver: ResolverFn) -> None: # noqa: N802
2188
+ """登记“这个种类的配置怎么变成能用的东西”。
2189
+
2190
+ `resolver(scope=…)` 返回 `list[Tool]`(或 awaitable);`scope` 是**作用域**(§10:
2191
+ qi 里最常见的作用域 = 一个 agent 目录)。同时隐含 `provides_config(kind)` ——
2192
+ 能解析就等于在管这个种类,不必再声明一次。
2193
+
2194
+ 这是 qi-mcp 那一侧:它声明 `mcp_servers` 怎么从三层 mcp.json 变成真的工具;
2195
+ 消费方(qi-agents / qi-web)只调 `resolveTools`,**不知道 MCP 存在**。
2196
+ """
2197
+ if self._capabilities is None:
2198
+ raise RuntimeError("宿主没有提供能力汇合点:register_resolver / registerResolver 不可用")
2199
+ self._capabilities.add_resolver(kind, self._name, resolver)
2200
+ self._config_kinds.add(kind)
2201
+
2202
+ async def resolve_tools(self, kind: str, *, scope: Any = None) -> list[Any]: # noqa: N802
2203
+ """问所有提供者要 `kind` 的工具,合并返回。
2204
+
2205
+ **没人提供 → 返回 `[]`(优雅降级)** —— 这是 E20 选“能力交接”而不是直接 import 的
2206
+ 全部理由:只装 qi-agents 时角色照跑,只是拿不到 MCP 工具,而不是装不上。
2207
+ """
2208
+ if self._capabilities is None:
2209
+ return []
2210
+ return await self._capabilities.resolve_tools(kind, scope=scope)
2211
+
2212
+ # ── pi 的驼峰别名(qi 的正式名是 snake_case)──
2213
+ registerTool = register_tool
2214
+ getAllTools = get_all_tools
2215
+ getActiveTools = get_active_tools
2216
+ setActiveTools = set_active_tools
2217
+ registerCommand = register_command
2218
+ registerShortcut = register_shortcut
2219
+ getCommands = get_commands
2220
+ runAgent = run_agent
2221
+ registerFlag = register_flag
2222
+ getFlag = get_flag
2223
+ registerCliCommand = register_cli_command
2224
+ registerProvider = register_provider
2225
+ unregisterProvider = unregister_provider
2226
+ appendEntry = append_entry
2227
+ sendMessage = send_message
2228
+ sendUserMessage = send_user_message
2229
+ registerMessageRenderer = register_message_renderer
2230
+ registerEntryRenderer = register_entry_renderer
2231
+ registerMarkdownTransformer = register_markdown_transformer
2232
+ setSessionName = set_session_name
2233
+ getSessionName = get_session_name
2234
+ setLabel = set_label
2235
+ setModel = set_model
2236
+ getThinkingLevel = get_thinking_level
2237
+ setThinkingLevel = set_thinking_level
2238
+ registerResolver = register_resolver
2239
+ resolveTools = resolve_tools
2240
+
2241
+
2242
+ __all__ = [
2243
+ "CommandRegistry",
2244
+ "EmitResult",
2245
+ "ExecResult",
2246
+ "ExtensionApi",
2247
+ "ExtensionBus",
2248
+ "ExtensionCommand",
2249
+ "ExtensionContext",
2250
+ "ExtensionEvents",
2251
+ "ExtensionHandler",
2252
+ "ExtensionShortcut",
2253
+ "ExtensionUi",
2254
+ "FlagRegistry",
2255
+ "FlagSpec",
2256
+ "ModelRegistryView",
2257
+ "ModelView",
2258
+ "RendererRegistry",
2259
+ "SessionView",
2260
+ "Tool",
2261
+ "ToolError",
2262
+ "ToolExecutor",
2263
+ "ToolOutcome",
2264
+ "exec_command",
2265
+ "register_tool",
2266
+ "tool_info",
2267
+ ]