pi-onlyne 0.9.1 → 1.0.0

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.
package/README.zh.md ADDED
@@ -0,0 +1,322 @@
1
+ # pi-onlyne —— pi 的 onlyne agent 适配器
2
+
3
+ 一个 pi 扩展:一个 pi 进程承载一个 onlyne role session。它连接
4
+ `<role workspace>/.onlyne/run/s`,按 `crates/onlyne-adapter/PROTOCOL.md` 通信,带 session 走完
5
+ `hello → welcome → assign → 工作 → complete → detach`。全程没有 Rust 代码:协议在 Node 的
6
+ `node:net` 上重写,四字节大端长度前缀加 UTF-8 JSON 的编解码是手写的,运行时零 npm 依赖。
7
+
8
+ 扩展在 onlyne 之外完全静默。客户端 spawn 进程时会注入 `ONLYNE_ROLE`、`ONLYNE_SESSION_ID`、
9
+ `ONLYNE_TASK_ID`(`crates/onlyne-client/src/dispatch.rs`);三者缺一,就是普通 pi session,
10
+ 插件不注册任何工具、不打开任何 socket。
11
+
12
+ ```
13
+ pi session(由 onlyne-client spawn)
14
+ │ 环境变量:ONLYNE_ROLE / ONLYNE_SESSION_ID / ONLYNE_TASK_ID
15
+ │ .pi/onlyne.json:{ "enabled": true, "watch": { "autoStart": true } }
16
+ ▼
17
+ hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{role,session,task_id,pid}}
18
+ ◀── welcome{role, prose, generation, server, host_capabilities}
19
+ ├─ prose ──► 注入 pi 上下文一次(custom message,不触发 turn)
20
+ ├─ report.ready ──► 载荷等待的那道 barrier
21
+ ◀── assign{envelope, prose, task_id, generation}
22
+ ├─ 任务文本(含图片路径)──► pi user message(deliverAs:"followUp")
23
+ ├─ assign_ack{accepted:true}
24
+ ├─ report.heartbeat{running|idle} —— 每个 turn,以及任务存续期间每 10 秒
25
+ ├─ report.complete{outcome, head} —— ledger 的终态事实
26
+ │ └─ client 的应答就是交接点:插件据此让 pi 退出,随后 detach
27
+ ├─ probe ──► 一条 heartbeat
28
+ ◀── recycle ──► (未终态则先 complete)→ 停插件 → pi 退出
29
+ └─ pi 退出时 detach{reason}
30
+ ```
31
+
32
+ ## 1. 安装
33
+
34
+ 这是一个 pi package:`package.json` 里声明 `pi.extensions: ["./src/index.ts"]`,pi 用 jiti
35
+ 直接加载 TypeScript,不需要构建产物。
36
+
37
+ ### 配合生成的工作区(正规路径)
38
+
39
+ `onlyne server generate` 会把 `[server].agent_package` 复制进
40
+ `<ws>/.onlyne/agent/<pkg-name>/`,再把这条 package 写进 `.pi/settings.json`,路径相对
41
+ settings 文件自身:`../.onlyne/agent/<pkg-name>`(`crates/onlyne-server/src/generate.rs`)。
42
+ pi 0.85.1 只加载这个写法。项目 `packages` 里的路径以 settings 文件所在目录(`<ws>/.pi`)
43
+ 为基准解析,所以 `../` 那份落到 `<ws>/.onlyne/agent/<pkg-name>`;裸写的
44
+ `.onlyne/agent/<pkg-name>` 会解析成 `<ws>/.pi/.onlyne/agent/<pkg-name>`,包被列出来却不
45
+ 加载。生成的工作区就是 supervisor 拉起的那份,插件随目录一起走,不装全局。
46
+
47
+ ```toml
48
+ # spec.toml
49
+ [server]
50
+ agent_package = "/abs/path/to/integrations/pi-onlyne" # 只在 generate 时读一次
51
+ ```
52
+
53
+ ```bash
54
+ onlyne server generate --root <server-root> --out <dir>
55
+ ```
56
+
57
+ 生成的 `.pi/settings.json` 形如:
58
+
59
+ ```json
60
+ { "packages": ["../.onlyne/agent/pi-onlyne"] }
61
+ ```
62
+
63
+ `pi list` 会把这条列在 “Project packages” 下。要验证真的加载了,就让复制进来的 `index.ts`
64
+ 抛错,看报错是否出现。
65
+
66
+ ### 手工(不经过 generate)
67
+
68
+ ```bash
69
+ cp -R integrations/pi-onlyne <ws>/.onlyne/agent/pi-onlyne
70
+ printf '{"packages":["../.onlyne/agent/pi-onlyne"]}\n' > <ws>/.pi/settings.json
71
+ ```
72
+
73
+ ### 一次性 / 测试
74
+
75
+ ```bash
76
+ pi --session-id <id> -e /abs/path/to/integrations/pi-onlyne -ns -nc
77
+ ```
78
+
79
+ ### 开关文件
80
+
81
+ `<cwd>/.pi/onlyne.json`(见 `onlyne.json.example`):
82
+
83
+ | 键 | 默认 | 作用 |
84
+ | --- | --- | --- |
85
+ | `enabled` | `true` | `false` 时该工作区禁用扩展 |
86
+ | `watch.autoStart` | `true` | `false` 时注册工具但不建连接,需 `/onlyne connect` |
87
+
88
+ 文件缺失即两个默认值。文件格式错误时打印一行警告,并保留默认值:一个笔误不该静默关掉一个
89
+ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为 generate 期模板提示),所以
90
+ 只有本扩展消费它;键名沿用模板里既有的形状。
91
+
92
+ 其余无需配置。工作区 `spec.toml` 的 `session_command` 已经按任务拉起 `pi`
93
+ (`["pi", "--session-id", "{session}"]`),client 负责注入本扩展识别的环境变量。
94
+
95
+ ## 2. 能力表
96
+
97
+ `hello` 只声明真实实现的能力:
98
+
99
+ | 能力 | 声明条件 | 含义 |
100
+ | --- | --- | --- |
101
+ | `register` | 始终 | `welcome` 之后发 `session_register{session_id, task_id, generation, pid, title}` |
102
+ | `report` | 始终 | `report.ready` / `report.heartbeat` / `report.complete` |
103
+ | `inject` | `pi.sendUserMessage` 存在 | 载荷以 `assign` 到达,并作为 pi user message 注入 |
104
+ | `recycle` | 始终 | 收到 `recycle` 先补终态,再停插件并让 pi 退出 |
105
+
106
+ 缺了某个 pi API 时会怎样,宿主怎么应对:
107
+
108
+ | 缺失项 | 探测时机 | 行为 |
109
+ | --- | --- | --- |
110
+ | `registerTool`(老 pi) | `session_start` | 不注册任何工具;协议通路不受影响,`/onlyne status` 仍可用 |
111
+ | `sendUserMessage` | `session_start` | capability 里去掉 `inject`,宿主改走 `config_get{key:"stdin:<text>"}`,插件用剩余的注入通道投递 |
112
+ | `sendMessage` | `session_start` | `welcome` 的 role prose 不再作为上下文注入;任务本身照常到达 |
113
+ | `appendEntry` | `session_start` | 不再写 `onlyne-assign` / `onlyne-complete` 会话条目 |
114
+ | `ui.setStatus` | 调用点保护 | 跳过 footer 状态行 |
115
+ | `ctx.shutdown` | 调用点保护 | `recycle` 与 completion 照常结算任务;进程留给操作者自己关闭 |
116
+
117
+ ## 3. 工具面
118
+
119
+ 仅在 onlyne session 内注册。
120
+
121
+ ### `onlyne_send{to, text, kind?, image?}`
122
+
123
+ 经 `send` 帧提交一个 envelope。`kind: "note"`(默认)是自由文本,不带 `op_id`。
124
+ `kind: "task"` 是派人办事,因此带 `o-<uuid>` 幂等键和新生成的 `causality.task`。`image` 是
125
+ png/jpeg/gif/webp 的绝对路径:插件读出内容,base64 编码后挂成 `body.image`。核心限 2 MiB,
126
+ 只收四种 mime。
127
+
128
+ ### `onlyne_complete{outcome?, text?, force?, reason?}`
129
+
130
+ 显式结束当前任务,`outcome` 缺省 `done`,也可 `failed`。`text` 非空时就是 ledger 的 `head`,
131
+ 原样写出:空白折叠成单行,截到 200 字符。`text` 缺失或全空白时不带摘要,completion 退回
132
+ 最后一段 assistant 文本。这一调用同时结束所在 session 的进程。client 应答完 completion
133
+ 报告(见 §4)之后,插件通过 `ctx.shutdown()` 让 pi 退出。pi 0.85.1 没有 tool-result
134
+ `terminate` 处理。工作区带接力策略(§5)时,`force: true` 加非空 `reason` 是绕过一个仍欠着的
135
+ 接力的正规通道。
136
+
137
+ ## 4. outcome 判定规则
138
+
139
+ 插件每个任务只发一次 completion,取以下三者的先到者:
140
+
141
+ 1. **`onlyne_complete`** —— 模型给显式 outcome,优先级最高;同一任务的第二次 completion 被
142
+ 拒(不重报)。`text` 非空时即 head,原样写出。
143
+ 2. **`agent_settled`** —— pi 不会自己继续:没有待重试、待压缩或排队续跑。此时:
144
+ - turn 以 provider 错误告终(`stopReason: "error"`)→ `failed`,错误信息当 head;
145
+ - 其余 → `done`,最后一段 assistant 文本当 head;
146
+ - 任务已投递但还没跑过任何 turn → 不发 completion。注入的消息尚未执行,这时报终态就是撒谎。
147
+ 3. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
148
+ 插件并退出 pi。
149
+
150
+ `head` 恒为单行、上限 200 字符,与 client 写入 `out_head` 和回执携带的内容一致。每个任务的
151
+ head 只有一个来源:显式 `onlyne_complete` 带的 `text`(有则原样采用),否则是最后一段
152
+ assistant 文本。自动规则就是那条退路:它报的是自己那一轮的文字,工具调用之后再说的话,顶不掉
153
+ 调用交出的内容。
154
+
155
+ 报出去的 completion 会结束所在 session 的进程。`report.complete` 以请求形式发出,client 只有
156
+ 在结算 session 行、ack 掉投递、并写好 `Completion` envelope 之后才应答,插件就在这个应答处
157
+ 让 pi 退出。socket 当时送不出去的 outcome 会被记住,并在下一次 `hello` 后补发,那次补发的
158
+ 应答就是结束进程的交接点。被宿主拒掉的 completion 不会让进程退出,任务不会因为退出而丢失。
159
+
160
+ 最后一条上报是:在已结算的 outcome 旁边带一个 `agent: "idle"` 的观测,发在 completion 被
161
+ ack 之后、进程退出之前。completion 是按 client 手里的元组结算 session 行的,而收尾那一轮
162
+ 就是最后一次 heartbeat 时,这个元组读到的仍是 `running`;此后没有任何东西再观测这个进程,
163
+ 所以缺了这条上报,已退出的 session 会一直说 `running`。最后一次心跳本来就是 idle 时,插件
164
+ 跳过这条;已结算的观测被拒,也不拖着 completion 挣来的那次退出不走。
165
+
166
+ ## 5. 接力守卫
167
+
168
+ 会话可以一件活都没交出去,就把 `done` 报掉。守卫堵的就是这个事故:一个 bench 会话边叙述进度边
169
+ 调 `onlyne_complete`,四个 todo 一个没动,下游 writer 永远等一条从未发出的接力。判据只是投递
170
+ 事实——某个 role 有没有被触达——绝不看发出去的文本长什么样、写得好不好。
171
+
172
+ 策略文件放在插件自己的 `package.json` 旁边,因此随 generate 出的工作区一起被带进去:生成的工作
173
+ 区里是 `<ws>/.onlyne/agent/pi-onlyne/relay.toml`,手工安装则是插件目录下的 `relay.toml`。
174
+
175
+ ```toml
176
+ relay_required = ["writer"] # 这些 role 必须收到过接力
177
+ relay_required_count = 2 # ……或至少这么多个不同的下游 role
178
+ ```
179
+
180
+ 两个键同时存在时以 `relay_required` 为准。
181
+
182
+ 策略属于 spec,不属于 vendor 目录。`onlyne generate --force` 会重写本插件被拷进去的那份副本,
183
+ 连带抹掉手写的 `relay.toml`;所以在 `[[client]]` 条目里写一次,client 就会把它注入到它拉起的
184
+ 每一个 session 进程:
185
+
186
+ ```toml
187
+ [[client]]
188
+ role = "planner"
189
+ relay_required = ["writer"] # 这些 role 必须收到过接力
190
+ relay_count = 2 # ……或至少这么多个不同的下游 role
191
+ ```
192
+
193
+ 来源优先级是 `环境变量 > relay.toml > 都没有`:`ONLYNE_RELAY_REQUIRED`(名单,逗号分隔)与
194
+ `ONLYNE_RELAY_COUNT`(数量,十进制)就是 client 按上面的条目填进去的两个变量;只有环境变量
195
+ 一个都没给出策略时,才去读 `package.json` 旁边的 `relay.toml`;两者都没有 = 无守卫。spec 两个键
196
+ 都写时 client 两个变量都注入,仍然以名单为准。手写的 `relay.toml` 仍是手工安装的逃生门——服务
197
+ 那些 spec 里根本没写策略的机器——被环境变量盖住的文件则完全不参与。设了但解析不了的变量,会在
198
+ stderr 告警并忽略,把机会让回文件。
199
+
200
+ | | |
201
+ | --- | --- |
202
+ | 默认 | 两个来源都没给策略 = 无守卫,completion 路径与守卫存在之前逐字节相同 |
203
+ | 判据材料 | 本会话自己成功 `onlyne_send` 触达过的 role,`note` 与 `task` 都算;被 client 拒掉的 envelope 不算 |
204
+ | 拒绝 | `onlyne_complete` 抛 `onlyne: relay guard: missing handoff to: writer (…)`,点名缺哪条边、怎么解除 |
205
+ | 拒绝之后 | 不上报、不排队、不 detach:session 仍然挂着,补上接力后同一次调用即可落地 |
206
+ | 名单模式 | 名单里每个 role 都要字面出现在已投递集合里 |
207
+ | count 模式 | 数不同的下游 role;发给本 role 自己、或回指派活的上游,都不算一个 |
208
+ | 作用域 | 本会话自己的投递,仅进程内存:重连不丢,会话重启从空开始,不去猜上一个进程发过什么 |
209
+ | 豁免 | `force: true` 加非空 `reason`;只在守卫拒绝时才起作用 |
210
+ | 审计 | 被豁免的 completion,ledger head 以 `relay-guard-forced: <reason>` 开头;调用带了 `text` 时紧接其后 |
211
+ | 不管的路 | 自动终态:`agent_settled` 与 `recycle{outcome}` 照旧结算欠着接力的任务 |
212
+
213
+ `relay.toml` 是 TOML 的封闭子集:扁平的 `key = value` 行、上面两个键、单行双引号字符串数组、
214
+ `#` 注释。子集之外一律 stderr 告警并忽略。它刻意不放 `.onlyne/config.toml`:client 以
215
+ `deny_unknown_fields` 解析那个文件,插件往里加键会让 client 直接起不来。
216
+
217
+ 没有策略时,`force` 与 `reason` 两个参数是惰性的。
218
+
219
+ ## 6. 协议说明与偏差
220
+
221
+ 下面每条要么是对 `PROTOCOL.md` 的明确解读,要么是在实际 client 上实测到的行为。
222
+
223
+ - **report 序号基址。** 插件自己的 `report` 序号从 1000 起,不是 1。client 把自身的派发事件
224
+ (`created`、资源 attach、`ready`)写进同一个 `(generation, seq)` 水位,reducer 会静默丢弃
225
+ 水位及以下的报告(`crates/onlyne-session/src/reconcile.rs`),所以从 1 起会丢掉最初的观测。
226
+ 其余版本语义与规范一致。
227
+ - **`observed` 是完整的 `Observation`。** `report.heartbeat` 携带整个合法状态元组
228
+ (`version`、`generation_live`、`isolate_after`、`terminate_after`、`mismatch_count`、
229
+ `agent`、`delivery`、`resource`、`recovery`、`outcome`、`public`),不是
230
+ `{"state": "running"}` 这种简写。宿主会反序列化它,`is_legal` 不接受的一律拒绝。本插件只管
231
+ `agent` 这一维(turn hooks),`delivery` 保持 `none`、`outcome` 保持 `pending`——在它报出
232
+ completion 之前这就是它的事实。`resource` 报 `attached`,因为宿主的派发路径已经记过这次
233
+ attach。
234
+ - **`ready` 每连接报一次。** 宿主的 hand-off 路径
235
+ (`crates/onlyne-client/src/dispatch.rs::hand_session`)在把 session 交给挂载的插件时已经报过
236
+ `ready`,所以插件再报一次在宿主侧是 no-op。插件仍然发送:先挂载、后有活正是 ready barrier
237
+ 描述的情形,而且只花一帧。
238
+ - **从不发 `cluster_ref`。** 本插件代表本地 role 说话,从不代表 aggregate;Rust 侧出于同样的
239
+ 原因把该字段写成 `skip_serializing_if` 缺省。
240
+ - **`probe` 用一条 heartbeat 应答**,对应 `PROTOCOL.md` 里 “`probe` declares fresh resource
241
+ observations”。
242
+ - **`config_get` 只有当键以 `stdin:` 开头时按任务正文处理**,这正是 `PROTOCOL.md` 为无
243
+ `inject` 插件记录的重载。其他键记日志后忽略,绝不误读。
244
+ - **`frame_too_large` / `bad_frame`**:超限正文在写出任何字节之前就被拒;帧错误关闭连接并重
245
+ 连。帧一旦损坏无法重新同步,这与 `crates/onlyne-frame/src/lib.rs` 的结论一致。
246
+ - **任务 id 在连接生命周期内一次性使用**:同一任务的重复 `assign` 插件只回
247
+ `reason: "duplicate"` 的 ack,不重复注入,并记住这个 id 直到连接结束。当今 client 每个任务
248
+ 都是新 uuid,所以这条只在真正的重投上生效。
249
+
250
+ - **pane 绑定(Orca tab)。** 在 Orca pane 里,插件在每个 heartbeat 上报自己跑在哪:报告
251
+ `Observation` 里的 `observed.host.orca.pane_key`(`crates/onlyne-session/src/host.rs`),环境
252
+ 报得出时还带上 `tab_id` / `leaf_id` 和终端的 `handle`。这个绑定是**继承**来的,不是猜的:Orca
253
+ pane 会把自己那四个 `ORCA_PANE_KEY` / `ORCA_TAB_ID` / `ORCA_LEAF_ID` / `ORCA_TERMINAL_HANDLE`
254
+ 导出给它启动的命令(2026-09-11 实测,Orca 1.4.198),而 client 会把自己的环境继续传给
255
+ session 命令。所以跑在 pane 里的那个进程,是唯一能从内部说出「这是哪个 pane」的组件;pi
256
+ 之后没有任何环节能恢复这个绑定。不在 pane 里时 `host` 键整个缺席:普通终端上的 pi 报的是
257
+ 一条没有 host 字段的 observation,而不是一条 pane 为空的。
258
+ - **为此不往 workspace 写任何东西。** 已经没有申报文件了:绑定搭在 client 本来就逐帧镜像的
259
+ 那份 observation 上。没有东西会创建它,所以不存在过期的申报,workspace 的缓存目录也不会
260
+ 被碰。这既让 `integrations/orca-plugin` 能不读任何路径就把 tab 轴收窄到真会话,也让
261
+ supervisor 在会话 *结束之后*仍然说得出它跑在哪:`report.complete` 会把 `host` 带过去。
262
+
263
+ ## 7. 配置项
264
+
265
+ | 环境变量 | 必需 | 作用 |
266
+ | --- | --- | --- |
267
+ | `ONLYNE_ROLE` | 是 | 挂载的 role |
268
+ | `ONLYNE_SESSION_ID` | 是 | 挂载的 session id;当前 client 中 session_id 等于 task_id |
269
+ | `ONLYNE_TASK_ID` | 是 | 本进程服务的任务;驱动 `session_register` 与首条 `ready` |
270
+ | `ONLYNE_SOCKET` | 否 | 覆盖 socket 路径(默认 `<cwd>/.onlyne/run/s`) |
271
+ | `ONLYNE_RELAY_REQUIRED` | 否 | 该 role 在 spec 里的 `relay_required`,逗号分隔:守卫的名单模式(§5) |
272
+ | `ONLYNE_RELAY_COUNT` | 否 | 该 role 在 spec 里的 `relay_count`:守卫的 count 模式,只在名单为空时起作用(§5) |
273
+ | `ORCA_PANE_KEY` | 否 | 本进程跑在哪(`<tab_id>:<leaf_id>`),每个 heartbeat 以 `observed.host.orca.pane_key` 上报;不在 Orca pane 里时未设置,这也是该字段缺席的原因 |
274
+ | `ORCA_TAB_ID` / `ORCA_LEAF_ID` | 否 | pane 的两个 id;只设了 pane key 时插件会自己解析 |
275
+ | `ORCA_TERMINAL_HANDLE` | 否 | 终端 handle,随 pane key 一起上报为 `host.orca.handle`,也是 `orca terminal switch` 要的那个值 |
276
+
277
+ 值得记住的常量:插件每 10 秒发一次心跳(`heartbeat_timeout_ms` 是 30 秒),`hello` 最多等
278
+ 5 秒,单次请求超时 30 秒,重连按 1/2/4/8/16/30 秒阶梯退避。
279
+
280
+ 插件自己读两个文件:`<cwd>/.pi/onlyne.json`(开关,§1)与 `package.json` 旁边的
281
+ `relay.toml`(接力策略的兜底,只在 client 没注入策略时才读,§5)。
282
+
283
+ ## 8. 故障排查
284
+
285
+ | 现象 | 原因 | 检查 |
286
+ | --- | --- | --- |
287
+ | 看不到 `[pi-onlyne] session …` | 三个环境变量缺一,或 `enabled` 为 false | `env \| grep ONLYNE_`;`cat .pi/onlyne.json` |
288
+ | `socket error: connect ENOENT …/.onlyne/run/s` | 该工作区没有 `onlyne-client run` | 起 client,或 `onlyne-client status` |
289
+ | 反复 `reconnecting in 4000ms` | client 已停或 socket 被替换 | `onlyne --server-root … roles` |
290
+ | `ready refused: internal: unknown session for …` | 插件为 client 从未暂存的任务报了 ready(手工起 pi 时的正常现象) | 让 client 拉起 pi,而不是手工起 |
291
+ | `assign` 一直不来 | client 的 `session_command` 没能拉起 pi,或 `inject` 被降级 | client 日志里的 spawn 行;`/onlyne status` 看能力集 |
292
+ | ledger 停在 `in_flight` | 没有 completion:没跑 turn,或 `agent_settled` 没触发 | pi session 文件里的 `onlyne-assign` / `onlyne-complete` 条目 |
293
+ | `onlyne_complete` 回答 `relay guard: missing handoff to: …` | 工作区的 spec(或顶替它的 `relay.toml`)点名了一个本会话从未触达的 role | 插件 stderr 的 `relay guard from …` 说明来源、`required=…` 说明策略;`relay guard: missing handoff …` 列出已投递集合 |
294
+ | `hello` 后立刻 `forbidden` / 断连 | mount role 与 client 的 role 不一致 | `hello.args.mount.role` 对该工作区的 role |
295
+ | `frame_too_large` | 正文超过 8 MiB | 只会由超限的出站图片触发;上限来自核心 |
296
+ | 工具缺失 | 该 pi 版本没有 `pi.registerTool` | `/onlyne status`;对照上面的能力表 |
297
+ | 会话在 `exited` 之后又回到 `idle` | completion 之后还落进了一条 heartbeat 快照,带着 `outcome: pending` | 看 session 日志里 `completion` 之后的 report 顺序;插件对已完成任务不再上报 |
298
+ | supervisor 看板一个 tab 都不列 | 没有 live session 上报过 pane:适配器版本早于这条上报,或这个 pi 不在 Orca pane 里 | `onlyne --server-root … sessions --json` 看 `projection.observed.host.orca.pane_key`;在 pane 里跑 `env \| grep ORCA_` |
299
+
300
+ `/onlyne status` 打印实时状态(`connected`、`socket`、`role`、`sessionId`、`generation`、
301
+ `agentState`、`tasks`、`pendingCompletion`、`lastError` 与计数器);`/onlyne connect` /
302
+ `/onlyne disconnect` 手工开合连接。
303
+
304
+ ## 9. 开发与验证
305
+
306
+ ```bash
307
+ cd integrations/pi-onlyne
308
+ node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫
309
+ ```
310
+
311
+ `src/agent.live.test.mjs` 只在 `target/debug/onlyne-client` 与 `onlyne-server` 存在时运行。
312
+ `crates/onlyne-testkit/e2e/pi-live.sh` 是端到端用例:pi 不在 PATH 或没有可用模型凭据时 SKIP
313
+ (exit 0),否则用真 client 跑一个真任务到 `acked`。
314
+
315
+ ```bash
316
+ cd ../..
317
+ ONLYNE_BACKEND=fake BIN_DIR=target/debug bash crates/onlyne-testkit/e2e/pi-live.sh
318
+ ```
319
+
320
+ 用例先 source 公共 helper,再自己导出 `ONLYNE_BACKEND=exec`,于是 pi 由 client 亲自 spawn,
321
+ stdin 是一条 client 持住不关的管道。agent 自己的输出落在
322
+ `<ws>/.onlyne/logs/session-<task>.log`。
@@ -0,0 +1,6 @@
1
+ {
2
+ "enabled": true,
3
+ "watch": {
4
+ "autoStart": true
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,54 +1,39 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.9.1",
4
- "description": "Pi extension tools for sending messages through Onlyne.",
3
+ "version": "1.0.0",
4
+ "description": "Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.",
5
5
  "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": "./dist/index.js",
9
- "files": [
10
- "dist",
11
- "README.md",
12
- "SPEC.md",
13
- "LICENSE"
14
- ],
15
- "scripts": {
16
- "build": "tsc -p tsconfig.json",
17
- "check": "npm run build && node --test test/*.test.mjs",
18
- "prepack": "npm run build",
19
- "prepublishOnly": "npm run check"
20
- },
6
+ "license": "MIT",
21
7
  "repository": {
22
8
  "type": "git",
23
- "url": "git+https://github.com/dbydd/pi-onlyne.git"
9
+ "url": "git+https://github.com/dbydd/onlyne.git",
10
+ "directory": "plugins/onlyne-agent-pi"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/dbydd/onlyne/issues"
24
14
  },
15
+ "homepage": "https://github.com/dbydd/onlyne/tree/main/plugins/onlyne-agent-pi#readme",
16
+ "files": [
17
+ "src",
18
+ "onlyne.json.example",
19
+ "relay.toml.example"
20
+ ],
25
21
  "keywords": [
26
22
  "pi-package",
27
23
  "pi",
28
24
  "onlyne",
29
- "messaging",
25
+ "adapter",
30
26
  "extension"
31
27
  ],
32
- "author": "dbydd <1992003927@qq.com>",
33
- "license": "MIT",
34
- "publishConfig": {
35
- "access": "public",
36
- "registry": "https://registry.npmjs.org/"
37
- },
38
- "devDependencies": {
39
- "@earendil-works/pi-ai": "^0.79.10",
40
- "@earendil-works/pi-coding-agent": "^0.79.10",
41
- "typebox": "^1.2.16",
42
- "@types/node": "^22.15.21",
43
- "typescript": "^5.8.3"
44
- },
45
28
  "pi": {
46
29
  "extensions": [
47
- "./dist/index.js"
30
+ "./src/index.ts"
48
31
  ]
49
32
  },
33
+ "scripts": {
34
+ "test": "node --test src/*.test.mjs"
35
+ },
50
36
  "peerDependencies": {
51
- "@earendil-works/pi-ai": "*",
52
37
  "@earendil-works/pi-coding-agent": "*",
53
38
  "typebox": "*"
54
39
  }
@@ -0,0 +1,18 @@
1
+ # Manual-installation escape hatch for the relay guard.
2
+ #
3
+ # A generated workspace gets its guard policy from spec.toml ([[client]] rows
4
+ # `relay_required = ["writer"]`, `relay_count = 2` — `relay_required_count` is
5
+ # accepted as the guard file's own spelling), which the client injects into
6
+ # every session it spawns. This file is for installations that manage their
7
+ # own workspace: drop it beside package.json and the guard reads it when the
8
+ # environment carries no policy. Environment wins over this file; no policy in
9
+ # either place leaves the guard off.
10
+ #
11
+ # relay_required wins over relay_count when both are present.
12
+
13
+ # Downstream roles one of this role's sessions must have handed work to before
14
+ # it may report a terminal outcome:
15
+ # relay_required = ["writer", "auditor"]
16
+
17
+ # ... or this many distinct downstream roles:
18
+ # relay_required_count = 2
@@ -0,0 +1,112 @@
1
+ // Integration: the plugin against a really-running `onlyne-client`.
2
+ //
3
+ // Everything else in this suite talks to a fake host built from the same
4
+ // framing code, which cannot catch a disagreement with the shipped binary. This
5
+ // case starts the real client's adapter socket (a workspace from
6
+ // `onlyne-client init`), opens the handshake, and reads the welcome the Rust
7
+ // side actually writes.
8
+ //
9
+ // The client's adapter socket binds before its server link, so no server and no
10
+ // task are needed to prove the live handshake. When the binaries are not built
11
+ // the case skips rather than failing: it verifies a build artifact, not source.
12
+
13
+ import assert from "node:assert/strict";
14
+ import { execFileSync, spawn } from "node:child_process";
15
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { test } from "node:test";
20
+
21
+ import { OnlyneAgent } from "./agent.mjs";
22
+
23
+ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
24
+ const BIN_DIR = join(REPO_ROOT, "target", "debug");
25
+ const CLIENT = join(BIN_DIR, "onlyne-client");
26
+ const SERVER = join(BIN_DIR, "onlyne-server");
27
+ const hasBinaries = existsSync(CLIENT) && existsSync(SERVER);
28
+ const TASK_ID = "11111111-1111-4111-8111-111111111111";
29
+ const SESSION_ID = "8b1c0d5e-2222-4222-8222-222222222222";
30
+
31
+ /** Wait until `predicate` holds; throws when the window closes. */
32
+ async function waitFor(predicate, { timeoutMs = 15_000, stepMs = 20 } = {}) {
33
+ const deadline = Date.now() + timeoutMs;
34
+ for (;;) {
35
+ const value = await predicate();
36
+ if (value) return value;
37
+ if (Date.now() > deadline) throw new Error("timed out waiting for the real client");
38
+ await new Promise((resolve) => setTimeout(resolve, stepMs));
39
+ }
40
+ }
41
+
42
+ test("a real onlyne-client answers hello with a welcome", { skip: !hasBinaries }, async () => {
43
+ const tmp = mkdtempSync(join(tmpdir(), "pi-onlyne-live-"));
44
+ const serverRoot = join(tmp, "server");
45
+ const workspace = join(tmp, "planner");
46
+ // `init` reads the server's spec.toml for the listen address and cert pin, so
47
+ // the server root is bootstrapped first; the server itself never runs, which
48
+ // is the point: the adapter socket is served independently of the link.
49
+ execFileSync(SERVER, ["init", "--root", serverRoot, "--listen", "127.0.0.1:7899"], { stdio: "pipe" });
50
+ execFileSync(CLIENT, ["init", "--workspace", workspace, "--role", "planner", "--server-root", serverRoot], {
51
+ stdio: "pipe",
52
+ });
53
+ const child = spawn(CLIENT, ["run", "--workspace", workspace], { stdio: ["ignore", "pipe", "pipe"] });
54
+ let clientLog = "";
55
+ child.stdout.on("data", (chunk) => { clientLog += chunk; });
56
+ child.stderr.on("data", (chunk) => { clientLog += chunk; });
57
+
58
+ const socketPath = join(workspace, ".onlyne", "run", "s");
59
+ const surface = {
60
+ available: { wakeUser: true },
61
+ calls: [],
62
+ wakeUser(text) { this.calls.push(text); return true; },
63
+ proseContext: () => true,
64
+ customEntry: () => true,
65
+ status: () => {},
66
+ welcome: () => {},
67
+ isIdle: () => true,
68
+ exit: () => {},
69
+ };
70
+ const logs = [];
71
+ const agent = new OnlyneAgent({
72
+ socketPath,
73
+ cwd: workspace,
74
+ role: "planner",
75
+ sessionId: SESSION_ID,
76
+ taskId: TASK_ID,
77
+ surface,
78
+ log: (line) => logs.push(line),
79
+ heartbeatMs: 60_000,
80
+ });
81
+ try {
82
+ await waitFor(() => existsSync(socketPath));
83
+ agent.start();
84
+ await waitFor(() => agent.status().connected, { timeoutMs: 15_000 });
85
+
86
+ const welcome = agent.welcome;
87
+ assert.equal(welcome.role, "planner", `client log: ${clientLog}`);
88
+ assert.equal(welcome.protocol, 1);
89
+ assert.equal(welcome.generation, 1);
90
+ assert.equal(welcome.sessionId, SESSION_ID, "the client echoes the mounted session");
91
+ assert.deepEqual(welcome.hostCapabilities, ["probe", "recycle"]);
92
+ assert.equal(typeof welcome.server.connected, "boolean");
93
+
94
+ // The client refuses a `ready` for a task it never staged, and that refusal
95
+ // is the proof the frame loop round-trips: it is a reply to a request this
96
+ // plugin sent after welcome, carrying a real error payload.
97
+ await waitFor(() => logs.some((line) => line.includes("ready refused: internal: unknown session for")), {
98
+ timeoutMs: 10_000,
99
+ });
100
+ assert.equal(agent.status().lastError, null);
101
+
102
+ } finally {
103
+ agent.stop("test");
104
+ child.kill("SIGTERM");
105
+ await Promise.race([
106
+ new Promise((resolve) => child.once("exit", resolve)),
107
+ new Promise((resolve) => setTimeout(resolve, 3_000)),
108
+ ]);
109
+ if (child.exitCode === null) child.kill("SIGKILL");
110
+ rmSync(tmp, { recursive: true, force: true });
111
+ }
112
+ });