rpi-cli 0.5.0

Terminal coding-agent CLI (the `rpi` binary) built on the rpi-* library crates
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
# rpi Rust 扩展开发最佳实践

本文是 rpi 创建 Rust `cdylib` 扩展的模型可读规范。扩展是 Rust 原生动态库,
通过稳定 C ABI(`rpi-plugin-sdk`)注册工具、事件处理器、资源发现、Provider
与渲染器,宿主(`rpi` CLI)负责加载与生命周期。创建完整 Agent 项目的默认结构见
`docs` 的 `agent` 主题;本指南聚焦扩展本身的 ABI 与生命周期。

## 1. 扩展模型总览

| 项目 | Rust 原生扩展 |
| --- | --- |
| 安装 | `rpi install crate-name`(预编译协议);`--build` / `--path`(源码) |
| 入口 | `rpi_plugin_register` C ABI 符号 |
| 开发反馈 | `rpi dev` watch + 热重载;`rpi dev-local` 隔离调试 |
| 静态资源 | `resources_discover` 或随项目放入 `.rpi` |
| 运行环境 | 本机动态库,当前用户权限 |
| 适用场景 | 本地工具、系统集成、性能敏感或纯 Rust 项目 |

扩展应遵循能力检测:只调用宿主明确提供的 capability,检查 `PluginApi`
对应注册槽位是否为 `Some`。缺少能力时返回清晰错误并优雅降级,不要 panic、
空指针或依赖 `undefined` 语义。

## 2. 推荐结构

```text
my-rpi-extension/
├── Cargo.toml
├── README.md
├── src/
│   ├── lib.rs
│   ├── tool.rs
│   └── error.rs
└── tests/
    └── behavior.rs
```

### Cargo.toml

```toml
[package]
name = "my-rpi-extension"
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/example/my-rpi-extension"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
rpi-plugin-sdk = "0.1"
serde_json = "1"
```

`cdylib` 是宿主加载的产物;同时保留 `rlib` 便于 Rust 集成测试。扩展边界只依赖
`rpi-plugin-sdk`,不要依赖 `rpi-cli`、`rpi-extensions` 或 harness 内部模块,
否则插件会和宿主实现耦合。

## 3. ABI 入口

宿主只加载统一 C ABI 符号 `rpi_plugin_register`。扩展使用 `export_plugin!`,
通过同一个 `PluginApi` 注册能力并调用 `api.declare` 声明优先级/平台。
SDK 在注册前检查 `abi_version` 和 `struct_size`;旧宿主缺少的尾部可选槽位补成
`None`,插件可据此降级。版本不兼容或必需字段不完整时返回非零状态,宿主报告加载失败。

```rust
rpi_plugin_sdk::export_plugin!(|api| {
    if let Some(declare) = api.declare {
        let status = declare(rpi_plugin_sdk::StbStringRef::from_str(
            r#"{"priority":60,"platforms":["linux"]}"#,
        ));
        if status != 0 {
            return status;
        }
    }
    let Some(register_tool) = api.register_tool else {
        return -1;
    };
    // 构建 StableToolSchema 和 execute/poll/cancel/destroy 函数表,
    // 然后调用 register_tool。完整实现参考 examples/plugin-stub。
    let _ = register_tool;
    0
});
```

`declare` 的 JSON 支持 `priority`(数字,越大越先)和 `platforms`(字符串数组,
如 `["linux","windows","macos"]`);宿主在分发事件处理器时跳过不匹配的平台。

### 能力检测

每个注册槽位都是 `Option`。注册前检查 vtable slot 是否为 `Some`,缺失时返回
清晰错误;不要依赖空指针或 panic 表达不支持。宿主对未知 runtime action ID
返回结构化错误,不会进入分发。runtime action 使用 SDK 的 `RuntimeActionId`
定义(含 `GetCliFlag`、`UiDialog`),不要自行假定数字范围。

### 独立子任务

`GetExecutionContext`(23)返回当前模型身份、thinking、活动工具、cwd 和会话 ID,
不包含凭据、认证头或 endpoint。`ChildAgent`(24)使用 `op` 分派:

- `start`:`task`(必填,最多 20,000 bytes)、`role`、`maxTurns`(1–32,默认 8)、
  `timeoutSeconds`(10–1800,默认 300),以及可选的父活动工具子集 `tools`。
  立即返回 `childId`、独立 `sessionId` 和 `status: running`。
- `poll`:传入 `childId`,消费 `deltas` 数组并取得 `running/done/failed` 状态。
  `done` 后还要检查 `result.status`、`result.text` 和 `result.turnLimited`。
- `cancel`:设置取消信号;`dispose`:取消并释放 handle。所有终态都必须 dispose。

子任务继承有效 provider 私有绑定、系统提示词/资源、项目 cwd 和权限 hook,
不重新加载默认配置,也不进入父 transcript。权限审批和用户问题使用父 UI,
无交互消费者时不会默认允许。`delegate_task` 从子工具注册表中删除。
插件工具仍属于父宿主,`__rpi.sessionId` 仍是父扩展状态空间,不能当成 child ID。
handle 按插件 owner 隔离;父结束/取消、会话切换、关闭或插件 reload 会清理。
每宿主最多保留 16 个任务,文本增量和最终报告共享 4 MiB、最多 8192 个增量。
这不是 OS 安全沙箱;只读任务应缩小工具范围并使用权限规则。

从工作线程调用同步 runtime action,避免在单线程 Tokio executor 中自锁。
ChildAgent 还必须绑定本次工具调用的 `__rpi.runtimeHandle` / `runtimeKey`:
用 opaque handle 作为 action 的 user_data,并在参数中传入 `__rpiRuntimeKey`;
不得跨调用缓存或输出给模型。仅注册级、失效和 child-originated 调用会被拒绝
(包括别名工具),不能经根宿主绕过工具子集。正确的父 capability 在取消后仅允许
poll/cancel/dispose 回收已有任务,不允许 start;排队中的 start 也继承原调用取消。
同步工具回调由宿主自动绑定。后台工具、provider 和审批 driver 自己保活 DLL 和参数;
超过两秒前台清理宽限期仍占用活动名额,直到实际退出。
宿主 runtime binding 弱持有父 harness;候选 reload 回退不会留下可解引用的失效 bridge 指针。
旧宿主不支持新 action 时明确提示升级,不能静默退回可能换模型的子进程。
完整协议与真实 DLL 测试见仓库 `docs/extensions/subagents.md`。

### 运行期间追加上下文

`AppendEntry` 的 `{"customType": "...", "data": {...}}` 形式只保存扩展数据;
默认不进入模型上下文。`{"message": <AgentMessage>}` 形式追加会话消息,但不
触发新一轮模型回复。运行期间消息先进入持久化队列,等当前轮的全部工具结果
提交后再写入历史并展示,下一次模型请求才能读取。

返回的 `entryId` 是稳定的保留 ID;排队期间查询该 entry 可能为空。工具内部
可以等待 `AppendEntry` 返回,它不会等待工具或整个运行结束。取消和会话恢复
不会丢弃已经接受的排队消息。

### SendMessage 的投递选项

`SendMessage` 接受原有 `{"message": <AgentMessage>}` / `{"text": "..."}`,
也接受 Pi 的自定义消息形状:`customType`、`content`、`display`、`details`。
`content` 可以是字符串或文本/图片块数组;缺失或 null 会归一化为空数组。

选项可以放在 `options` 对象中,也可以放在参数顶层;两种形式不要混用:

```json
{
  "message": {"customType": "goal-state", "content": "目标状态已更新", "display": true},
  "options": {"triggerTurn": false}
}
```

| 选项 | 行为 |
| --- | --- |
| 不传 `triggerTurn` | 空闲时只追加上下文;运行时默认进入 steering 队列 |
| `triggerTurn: true` | 空闲时发起模型运行;运行时按 `deliverAs` 排队 |
| `triggerTurn: false` | 只更新上下文,运行时等当前工具批次结束后提交;不主动追加模型回复 |
| `deliverAs: "steer"` | 默认模式,在当前工具批次结束后注入 |
| `deliverAs: "followUp"` | 当前任务完成后注入,再继续处理 |
| `deliverAs: "nextTurn"` | 留到下一次显式发起的运行,在新输入之前加入;优先于 `triggerTurn` |

返回值区分 `status: "appended"`、`status: "queued"` 和原有运行结果状态。
追加和排队返回稳定的 `entryId`;排队消息提交前查询 entry 可能为空。
排队结果中的 `deliverAs` 表示投递方式;静默上下文更新返回 `queue: "context"`。
steering/follow-up 不会重入正在执行的工具,`nextTurn` 也不会被自动恢复运行提前消费。

默认空闲发送现在与 Pi 一致,不再自动请求模型。原来依赖默认发送立即回复的
调用,应显式传 `triggerTurn: true`。

## 4. 工具生命周期:execute → poll → cancel → destroy

工具导出四个 `extern "C"` 函数,宿主按固定协议驱动:

- `execute` 解析宿主传入的 JSON(owned `StbString`,用 `free_with` 释放),
  创建每次调用独立的 handle(`Box::into_raw`)。
- `poll` 必须非阻塞;未完成返回 `Pending`,完成返回 `Done` 或 `Err`。
  `partial_cb` 在 poll 内同步调用,宿主在等待终态期间实时转发 partial。
  宿主对 Pending 短暂退避,取消后等待 driver 结束不空转;迟到的成功不覆盖取消。
- `cancel` 只设置线程安全取消标记,必须幂等,不能释放 handle。
- `destroy` 由宿主最终调用一次并释放 handle(空指针 no-op)。

所有权协议:

- 宿主产生的 `StbString`(execute 的 params、事件 payload)由插件通过宿主的
  `free_string` 释放。
- 插件产生的 `StbString`(done 结果、partial 进度、resources_discover 的 out)
  由宿主通过插件注册时提供的 `plugin_free_string` 释放。
- 每个 `extern "C"` 边界都不能 unwind;插件内部捕获 panic 并转换为错误结果。

完整、可运行的 ABI 模板位于 `examples/plugin-stub/src/lib.rs`。创建新工具时先
复制生命周期骨架,再替换参数 schema 和领域逻辑,不要重新设计所有权协议。

### 注入到工具参数里的宿主上下文

宿主在把参数交给插件**之前**,会往这次调用的 JSON 参数里加一个保留键
`__rpi`(`rpi_plugin_sdk::HOST_CONTEXT_KEY`):

```json
{ "action": "add", "text": "…",
  "__rpi": { "cwd": "D:\\Projects\\pi-rust",
             "sessionId": "01adb026-7230-708b-855b-91eab6b056b8" } }
```

- **模型看不到、也写不了。** 注入发生在模型参数解析之后、`execute` 之前,不
  会进 `ToolExecutionStart`/`Update` 事件,也不会进会话日志;模型若碰巧传了同
  名键,宿主的值覆盖它。
- **两个字段都可能是缺失的。** 拿不到工作目录时没有 `cwd`;ephemeral 会话以及
  早于该注入的旧宿主没有 `sessionId`;整个键也可能不存在。
- **所以 tool 的参数类型必须容忍未知的顶层字段。** 用 `serde_json::Value`
  取值天然满足;给参数定义 `#[serde(deny_unknown_fields)]` 的结构体会直接失败。
- 内置工具不走这条路——它们从 harness 直接拿到 `ExecutionToolContext`。

需要"每个会话一份状态"的插件就用 `sessionId` 做键(`rpi-todo` 存
`.rpi/todos/<sessionId>.json`,一条会话一个文件),取不到时退回项目级并**在
回复里说明**,不要编一个共享的伪会话——那正是它要解决的问题。

## 5. 事件处理器、资源发现与 Provider

### 事件处理器

通过 `api.register_event_handler` 按 `EventTag`(36 类)订阅:

- **会话生命周期**:`SessionStart`、`SessionBeforeSwitch`、`SessionBeforeFork`、
  `SessionBeforeCompact`、`SessionCompact`、`SessionShutdown` 等。
- **Agent 循环**:`BeforeAgentStart`、`AgentStart`、`AgentEnd`、`AgentSettled`、
  `TurnStart`、`TurnEnd`、`MessageStart`/`MessageUpdate`/`MessageEnd`、
  `ToolExecutionStart`/`Update`/`End`、`ToolCall`、`ToolResult`、`UserBash`、`Input`。
- **rpi 特有 / UI**:`BeforeTuiStart`(TUI 初始化前)、`UiPromptStart`/`UiPromptEnd`。

处理器收到 `StablePluginEvent`,不得释放事件里的字符串(宿主 fan-out 后统一释放)。
返回 `0` 成功;返回 `EVENT_HANDLER_ABORT` 表示 veto。

### 生命周期 veto

`BeforeTuiStart` 是带 veto 语义的 rpi 专有钩子:第一个返回 `EVENT_HANDLER_ABORT`
的处理器中止启动,CLI 以退出码 `3` 退出(用于"进入 TUI 前必须先建立外部连接")。
`SessionStart`/`SessionShutdown` 的 veto 只是 advisory。分发带超时,慢处理器不会
无限阻塞主流程。

### 资源发现

`api.register_resources_discover` 注册 handler,宿主在 `startup`/`reload` 时
fan-out 到所有已注册 handler,合并返回的 `{skillPaths, promptPaths, themePaths}`。
单个 handler 报错不中止 fan-out。`out` 是插件拥有的 `StbString`,宿主通过插件
注册时给的 `plugin_free_string` 回收。

### 运行时改写系统提示词

`api.register_before_agent_start` 注册系统提示词变换器,对应 pi 的
`before_agent_start` handler 返回 `systemPrompt`(成为该次运行的
`forceSystemPrompt`)。这是插件**运行时**改变系统提示词的唯一正规入口:

```rust
// event_json 里带 prompt / imageCount / sessionId / host,以及**当前** systemPrompt。
extern "C" fn on_before_agent_start(
    event_json: StbStringRef,
    out: *mut StbString,
    user_data: *mut c_void,
) -> i32 {
    rpi_plugin_sdk::guard_or(1, || {
        let env: serde_json::Value =
            serde_json::from_str(unsafe { event_json.as_str() }).unwrap_or_default();
        let current = env["systemPrompt"].as_str().unwrap_or("");
        // 返回值是**替换**而非追加:想追加就读当前值再拼回去(pi 同样要求)。
        let next = format!("{current}\n\n本仓库优先使用 `just test` 而非 `cargo test`。");
        unsafe { *out = StbString::from_string(json!({ "systemPrompt": next }).to_string()); }
        0
    })
}
```

语义要点(与 pi 对齐):

- **替换,不是追加。** 返回的文本成为提示词。想追加必须自己读 `eventJson` 里的
  `systemPrompt` 再拼接——宿主不会帮你保留原文。
- **按注册顺序串联。** 后一个 handler 收到的是前一个 handler 返回的结果。
- **单个 handler 失败不致命。** 返回非零或 panic 时,提示词保持前一个 handler 的
  结果,fan-out 继续(宿主记日志 + 跳过)。
- **只影响该次运行。** 宿主每轮重新从会话状态推导提示词,插件的结果不会被持久化。
- **每个用户 prompt 只算一次。** 一次 prompt 可能触发多轮 provider 调用(工具循环),
  变换器只在第一轮前运行并缓存结果,避免同一运行中途漂移。
- **返回空对象/空串 = 不变。** 只想观察不想改的插件可以什么都不返回;显式返回空
  字符串则是“清空提示词”,会被采纳。

`out` 与其他 out-param handler 一样由插件拥有、宿主经 `plugin_free_string` 回收。
该槽位在结构体末尾新增且可为 `None`:面向旧宿主的插件应检测后再注册(见 §3
能力检测),宿主则在没有插件注册时完全不进入这条路径。

### Provider 与渲染器

推理 provider 的目录、认证、执行绑定与验收要求见本文末尾的「Provider 接入规范」。

`api.register_provider` 注入自定义 LLM Provider(`provider_id`/`base_url`/
`api_style` + `request_fn`)。`register_message_renderer`/`register_markdown_transformer`/
`register_entry_renderer` 注册消息渲染能力。这些槽位都可能为 `None`,使用前必须检测。

`api.register_streaming_provider` 注入**流式** Provider。注册时同样提供 provider ID、
端点、API style 和字符串释放回调;流式 request fn 另有逐块回调 `emit`、
`emit_user_data`、可选控制面 `control` 和当前宿主恒为 `None` 的 `completion`。
插件在请求还没结束时调用 `emit`,把增量推给宿主:

```json
{"type":"text_delta","contentIndex":0,"delta":"hello "}
{"type":"thinking_delta","contentIndex":1,"delta":"weighing"}
{"type":"toolcall_delta","contentIndex":2,"delta":"{\"path\""}
```

宿主即时把它们映射成 `AssistantMessageEvent`(`TextDelta`/`ThinkingDelta`/
`ToolCallDelta` 等),所以 TUI 在模型还在生成时就开始渲染,而不是等整段返回。
可用的 `type`:`start`、`text_start`/`text_delta`/`text_end`、
`thinking_start`/`thinking_delta`/`thinking_end`、
`toolcall_start`/`toolcall_delta`/`toolcall_end`、`usage`;未知 `type` 忽略(向前兼容)。

约定:

- **`out` 仍是最终消息。** 回调只送增量;插件返回时写入 `out` 的完整
  `AssistantMessage` 才是终态 `Done`/`Error` 的载荷。宿主会在终态前补一个 `Start`,
  所以即使插件一个块都没发,事件流也合法。
- **回调的所有权与工具 partial 一致。** 每个 chunk 都是插件分配的 `StbString`,宿主
  用插件注册时给的 `plugin_free_string` 回收;`emit`/`emit_user_data` 只在本次请求
  期间有效,插件不得保存。

该槽位同样追加在结构体末尾且可为 `None`:面向旧宿主时应降级到 `register_provider`
(一次性)而不是拒绝加载。同一 `provider_id` 先一次性、再流式注册时,流式会原地替换
该记录(流式是一次性的超集)。可从 `examples/plugin-stub/src/lib.rs` 的
`on_provider_stream_request` 复制完整可运行的骨架。

#### 取消:同一个方法,插件自行决定

长时间生成需要能被中断。**没有单独的「可取消」注册**:`register_streaming_provider`
只有一个方法、一个签名,request fn 永远会收到一个 `control` 参数(宿主提供的同步
控制面)。要不要轮询它,由插件自己决定。

```rust
// 每个 SSE frame 之间(真实 provider 通常在这里阻塞在 socket read 上):
if (control.is_cancelled)(control.user_data, ProviderChunkKind::Content as u32) != 0 {
    return 1; // 调用方已经走了,停止生成
}
```

- `is_cancelled(user_data, chunk_kind)`:非阻塞轮询。传 `Content`(0)表示
  “模型输出”,传 `Structural`(1)表示 `start`/`*_end`/`usage` 这类收尾帧。
  取消后宿主确认结构帧、拒收内容帧;已发出终态后的收尾帧会被丢弃。
  取消终态保留已转发的内容、模型标识和用量。插件持有 DLL 引用直到请求返回,
  后续写入 `out` 的成功结果不会覆盖取消终态。
- `wait_cancelled_ms(user_data, timeout_ms)`:阻塞版,供无法轮询的循环使用;
  已取消时会立即返回,不会睡满超时。

宿主的取消信号来自 `options.signal`(和内置 provider 同一个 `CancellationToken`,
用户中断时由 agent loop 取消)。取消发生后宿主会:latch 控制面、停止转发内容 delta、
**立即**发出 `Error { reason: Aborted }` 终态并结束事件流——不再等一次没人要的完整
生成。`emit` 对内容帧返回非 0 也算同一个信号,插件可据此退出。

轮询 `control` 是**可选**的,两种写法都合法:

- **不轮询**(fire-and-forget):插件照常把整段产出推完。取消时宿主仍会停止转发内容
  帧并立刻结束该轮,插件只是多花了它已经产出的那部分 token。
- **轮询**:插件在最早一个可检查点退出,省掉剩余生成。

`control` 可能是 `null`(面向不支持控制面的宿主);此时当作「不可取消」,不要拒绝加载。

`completion` 参数在**当前宿主上恒为 `None`**:本宿主不允许插件在 request fn 返回后
继续回调(那会让回调指针比调用活得久)。插件必须在 request fn 内收尾并写 `out`;
ABI 里保留该参数是为了将来宿主能扩展,而不必再改签名。

插件只需按 流式 → 一次性 逐级降级:

```rust
if let Some(register) = api.register_streaming_provider { /* 流式,可选轮询 control */ }
else { /* register_provider,一次性 */ }
```

可从 `examples/plugin-stub/src/lib.rs` 的 `on_provider_stream_request`(不轮询)和
`on_cancellable_provider_stream_request`(轮询)各复制一份骨架。

## 6. Rust 工具设计最佳实践

1. 把领域逻辑写成普通 Rust 函数,FFI 函数只负责转换、生命周期和错误映射。
2. 对普通逻辑写 `rlib` 单元测试;对 ABI 注册、加载和卸载写至少一个宿主 smoke test。
3. Schema 和实现使用同一字段命名,拒绝未知字段;对路径、URL、命令、数量和输出大小设置上限。
4. `poll` 不执行长时间阻塞任务。耗时工作放入线程/任务,handle 保存状态;取消标记必须能让任务在有限时间内结束。
5. 全局可变状态使用明确同步,避免把 session 或 tool call 状态存进无保护的 `static mut`。
6. runtime action、provider、renderer、event handler 和 resources handler 都是可选能力;注册前检查 vtable slot。
7. 插件注册失败返回非零;单个工具执行失败返回结构化错误,不要让整个宿主退出。
8. README 记录工具 schema、权限边界、持久化文件、网络访问、支持平台和卸载方式。

## 7. 使用 rpi dev 开发

在扩展 crate 目录运行:

```bash
rpi dev
```

rpi 会通过 Cargo metadata 识别当前 `cdylib`,首次编译后把版本化副本放在工作区
`.rpi/extensions/.dev/`,启动正常 TUI,并监控 `src/`、`build.rs`、package/workspace
`Cargo.toml` 和 `Cargo.lock`。源文件变化后会自动编译并走现有 reload 流程;编译失败
会保留当前已加载版本。

多扩展 workspace 必须明确选择 package:

```bash
rpi dev --package rpi-todo
rpi dev -P rpi-webfetch
```

其他模式:

```bash
rpi dev --release
rpi dev --no-watch
rpi dev-local
rpi dev --package rpi-todo -- --model gateway/model
```

调试单个扩展时使用 `rpi dev-local`(等价于 `rpi dev --local-only`)。它只加载当前
编译的扩展、当前项目目录的 skill/prompt,以及该扩展发现的 skill/prompt;不会扫描
全局扩展、已安装 Pi 包或其他项目资源,避免同名命令和无关命令干扰调试。内置命令
(包括 `/reload`)仍然可用。没有 Cargo cdylib 时优雅降级为 skills-only 模式。

TUI 中执行 `/reload` 会强制重新运行 Cargo build,再加载新阶段产物。Windows 上
已加载 DLL 无法原地覆盖,因此不要手工把 Cargo target DLL 复制到固定文件名;
`rpi dev` 的版本化 staging 会安全地完成切换,并在会话退出、loader 释放后清理。

## 8. 资源与配置约定

项目级资源放在 `.rpi/`。同名资源优先级是项目内定义优先
高于 `.pi`,项目高于全局,package 最后:

```text
.rpi/
├── SYSTEM.md
├── APPEND_SYSTEM.md
├── settings.json
├── skills/
├── prompts/
├── themes/
└── extensions/
```

动态 `resources_discover` 适合根据运行环境决定路径;固定资源优先使用 manifest 或
约定目录,便于安装器、欢迎页和 `/context` 在扩展代码运行前发现它们。

### 自定义加载路径

项目级 `.rpi/settings.json` 可以声明额外资源路径;无需其他兼容
回退。路径相对于项目根目录,配置路径会排在约定目录前面:

```json
{
  "skillDirs": ["./team-skills"],
  "promptDirs": ["./prompts/shared"],
  "extensionDirs": ["./target/debug"],
  "packages": ["./packages/review-tools"]
}
```

`skills`、`prompts`、`extensions` 也可分别作为三个路径数组的简写。全局
`~/.rpi/agent/settings.json` 支持相同的 `skillDirs`、`promptDirs`、`extensionDirs`,
其中相对路径相对于全局 agent 目录。项目配置优先于全局配置;`.rpi` 资源和设置排在
`.pi` 之前。

单次运行可以显式追加路径:

```bash
rpi --skill ./extra-skills
rpi --prompt-template ./extra-prompts
rpi --extension ./target/debug/my_extension.dll
rpi --extensions-dir ./target/debug
```

`--skill`、`--prompt-template`、`--extension` 和 `--extensions-dir` 都可以重复使用。
额外的 Rust 扩展目录也可通过 `RPI_EXTENSIONS_DIR` 配置;Windows 使用分号分隔,
Linux/macOS 使用冒号分隔。命令行和环境变量适合本机开发、CI 与排错;团队共享配置
使用项目 `.rpi/settings.json`。

## 9. 发布检查清单

- `cargo fmt --check`、`cargo clippy --all-targets`、`cargo test` 通过。
- `crate-type` 包含 `cdylib`,使用 `export_plugin!` 导出
  `rpi_plugin_register`。
- 依赖已发布的 `rpi-plugin-sdk` 兼容版本,不链接宿主私有 crate。
- `rpi dev --no-watch` 能编译、加载并注册预期工具。
- `rpi install <crate> --build --force` 的干净安装路径通过。
- Linux/macOS/Windows 构建由 CI 验证,README 标明支持矩阵。

## 10. 模型执行规则

模型收到"创建 rpi Rust 插件、扩展工具"任务时:

1. 先调用 `docs` 查询 `authoring`(扩展开发)或 `debugging`(调试)。
2. 阅读当前仓库现有 manifest、SDK 版本和相邻扩展约定,不猜 API。
3. 优先复制 `examples/plugin-stub` 的最小骨架。
4. 实现领域逻辑与边界测试,再连接 loader/ABI。
5. 使用 `rpi dev --no-watch` 与 `rpi install <crate> --build --force` 做真实 smoke test。
6. 不声称未验证的 Pi capability 已完全兼容;明确记录降级行为。

## Provider 接入规范

统一生产包为 `rpi-providers`,一个安装入口包含 DeepSeek、OpenRouter、llama.cpp 和 Proxy。
新增供应商优先在包内添加模块;只有根模块导出一次 `export_plugin!`,各模块共用注册桥接。
provider ID、目录与凭据继续独立。单 provider 教学样例才使用 `export_provider!`。

完整规范:[Provider 接入规范](https://github.com/bigfish1913/pi-rust/blob/feature-stream-porider/docs/extensions/provider-integration.md)。创建推理扩展时优先复制
正式 provider package 示例见 [rpi-providers](https://github.com/pi-rust/rpi-package/tree/feature-1014/packages/rpi-providers),通用工具扩展再使用 `plugin-stub`。当前接入约定如下。

### 方式与目录

只改已支持协议的端点、模型和 key 时,优先使用 `models.json`;需要可安装目录、
认证策略或供应商逻辑时,使用独立 package。新供应商不添加为 CLI 的默认依赖。
Provider 负责推理,auth 负责凭据和登录流程,两者独立注册。

通过 `declare` 的 `providers` 数组声明 `id`、完整 `rpi_ai::Model` 数组 `models`、
可选 `apiKeyEnv` 和默认 `true` 的 `requiresAuth`。用 `Model::new` 构造后序列化,
不能使用 models.json 的简写对象。provider ID、每个模型的 `provider`、实际注册 ID
必须一致;model ID 是发送给服务端的名称。匿名服务必须显式设置 `requiresAuth: false`,
协议客户端也必须允许匿名,不能只修改目录声明。

目录只在同一插件成功注册对应 provider 后生效,同名冲突时保留第一份实现及其目录。
注册和目录发现不要求凭据;宿主在选择模型前加载扩展、合并 models.json 覆盖。
`--list-models` 可无凭据显示目录;`--no-extensions` 同时禁用实现和声明。

### Factory 与私有执行绑定

统一包内复用 `crate::registration::register_provider!`;单 provider 教学包可使用
`rpi-provider-runtime::export_provider!`。模块提供
`fn(&Model, &SimpleStreamOptions) -> Arc<dyn Provider>`。OpenAI-compatible 接入可复用
`rpi_provider_runtime::completions(model, options, anonymous)`,无需复制 SSE 解析器。
宿主私有 `ProviderFactory` 统一选择扩展 / 内置协议,不是插件需要实现的接口。
运行时依赖使用 `rpi-ai`、`rpi-plugin-sdk` 与桥接库,不依赖 `rpi-cli`;宿主加载器
可以作为测试依赖。

公开目录、当前模型及配置后 `Provider::models()` 返回的描述不携带请求头。
宿主私有 `ExecutionBinding` 保存实际端点、模型请求头及默认认证,执行前按
provider / API / model ID 恢复;修改公开描述不能重定向绑定的凭据。
`base_url` 仍公开用于展示,每次请求显式传入的 options 仍按既有规则生效。
插件必须使用请求传入的 model / options,不能改用硬编码端点或进程环境变量中的 key。

### 认证与重载

默认凭据由宿主按 provider ID 解析:选中 provider 的 `--api-key` → `auth.json`
有效 key / OAuth → `models.json` → 声明的 `apiKeyEnv` → 显式允许的匿名。
协议名不决定认证,不继承 `OPENAI_API_KEY`;空白、未解析引用和过期 token 不算可用。
目录不得含凭据;请求参数、token 与私密请求头不得进入日志、普通事件或错误文本。
OAuth 供应商流程另外通过 `register_oauth_provider` 接入,宿主负责通用命令与存储。

`auth check`、`/provider` 与实际请求使用同一认证解析。`auth check` 只检查本地状态,
不访问服务端或刷新 token;退出码为就绪 0、缺少凭据 1、配置错误 2。
`/provider <id>` 与列表选择会即时切换当前会话并保存默认模型;缺少认证或切换失败时
保留原会话与默认值。`/model` 选择具体模型;`/provider remove` 删除配置,
卸载 package 使用 `rpi package unlink`。API-key logout 不会清除环境变量或 models.json。

涉及 provider package 的 `/reload` 会重新绑定 runtime、凭据、端点与公开目录;
当前 package 消失或无法重新解析时返回错误并保留旧会话。仅内置 provider 的会话仍使用
既有资源重载逻辑,不承诺重新解析所有 provider 配置;完整重载尚非原子代次切换。

### 流式与验收

增量必须在生成期间发出,最终 `out` 仍提供完整消息,宿主只产生一个 Done / Error。
取消要及时停止请求并保留部分输出;同步 request fn 返回后禁止继续调用 `emit`。
使用桥接库处理 ABI 字符串释放、取消与 runtime 生命周期,手写 ABI 遵守前述协议。

验收必须包含真实动态库加载、本地 SSE 在首个正文增量到达后才继续发送的门控测试、
UTF-8 跨块、usage、唯一终态、响应未结束时取消与 HTTP 401;认证隔离、端点 / 请求头
覆盖、公开目录无认证头、命令与重载也要验证。工具和 OAuth 只声明已完成验证的能力。
真实 API 测试默认 ignored,显式运行时从环境注入 key 并限制超时、重试和用量。
正式统一包位于独立的 [rpi-package 项目](https://github.com/pi-rust/rpi-package/tree/feature-1014/packages/rpi-providers),
保留 `deepseek` ID,声明 `DEEPSEEK_API_KEY`,并通过 `requiresReasoningContent` 支持工具续轮。

在 rpi-package 仓库运行正式 package 的离线验证:

```sh
cd /path/to/rpi-package
cargo build -p rpi-providers
cargo test -p rpi-providers --test streaming
cargo test -p rpi-providers --test host
```

在 pi-rust 仓库运行 CLI 集成测试时设置 `RPI_TEST_PROVIDER_LIBRARY` 和
`RPI_TEST_PROVIDER_PACKAGE`,再执行:

```sh
cargo test -p rpi-cli --test provider_packages
```

新增 package 还必须构建并测试自身动态库,不能仅凭宿主测试通过就声称接入完成。