pi-onlyne 1.2.1 → 2.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.md +147 -240
- package/README.zh.md +79 -125
- package/package.json +2 -3
- package/src/activity.test.mjs +0 -6
- package/src/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -395
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/config.test.mjs +0 -8
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +110 -30
- package/src/pi-surface.test.mjs +232 -0
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -69
- package/src/socket.mjs +270 -39
- package/src/socket.test.mjs +205 -47
- package/relay.toml.example +0 -18
- package/src/relay.mjs +0 -299
- package/src/relay.test.mjs +0 -210
package/README.zh.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# pi-onlyne —— pi 的 onlyne agent 适配器
|
|
2
2
|
|
|
3
|
-
一个 pi 扩展:一个 pi 进程承载一个 onlyne role session
|
|
4
|
-
`<
|
|
3
|
+
一个 pi 扩展:一个 pi 进程承载一个 onlyne role session。它连接该工作区的 client 所服务的
|
|
4
|
+
socket——机器级运行目录里的 `<digest>.sock`(`/tmp/onlyne-<uid>/`,`ONLYNE_RUNTIME_DIR` 可覆盖),
|
|
5
|
+
而不是树内的任何路径——按 `crates/onlyne-adapter/PROTOCOL.md` 通信,带 session 走完
|
|
5
6
|
`hello → welcome → assign → 工作 → complete → detach`。全程没有 Rust 代码:协议在 Node 的
|
|
6
7
|
`node:net` 上重写,四字节大端长度前缀加 UTF-8 JSON 的编解码是手写的,运行时零 npm 依赖。
|
|
7
8
|
|
|
@@ -16,16 +17,19 @@ pi session(由 onlyne-client spawn)
|
|
|
16
17
|
▼
|
|
17
18
|
hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{role,session,task_id,pid}}
|
|
18
19
|
◀── welcome{role, prose, generation, server, host_capabilities}
|
|
19
|
-
├─ prose ──►
|
|
20
|
+
├─ prose ──► 系统提示里的一个 `onlyne-role-prose` section,只写一次
|
|
20
21
|
├─ report.ready ──► 载荷等待的那道 barrier
|
|
21
|
-
◀── assign{envelope, prose, task_id, generation}
|
|
22
|
-
├─
|
|
22
|
+
◀── assign{envelope, prose, text, attachments, task_id, generation}
|
|
23
|
+
├─ prose(仅当 welcome 尚未投递过) ──► 同一个 section
|
|
24
|
+
├─ text ──► pi user message(deliverAs:"followUp"),逐字节原样;`body.image`
|
|
25
|
+
│ 作为 pi image part 同行,`attachments` 里的路径是 client 已写好的文件
|
|
23
26
|
├─ assign_ack{accepted:true}
|
|
24
27
|
├─ report.heartbeat{agent} —— 每个 turn 以及任务存续期间每 10 秒发 `running`,
|
|
25
28
|
│ 只有 pi 等待输入时才发 `idle`;每次心跳都重新向 pi 推导
|
|
26
|
-
├─
|
|
27
|
-
│
|
|
28
|
-
|
|
29
|
+
├─ 轮次结束的规则归 client:没有 completion 的轮次结束换来一次 nudge,
|
|
30
|
+
│ 第二次这样的结束就结算这次投递
|
|
31
|
+
◀── nudge{task_id, text} ──► pi user message,逐字节原样(client 自己的句子)
|
|
32
|
+
├─ report.complete{outcome, head, details, files} —— ledger 的终态事实,也是最后一份报告
|
|
29
33
|
│ └─ client 的应答就是交接点:插件据此让 pi 退出,随后 detach
|
|
30
34
|
├─ probe ──► 一条 heartbeat
|
|
31
35
|
◀── recycle ──► (未终态则先 complete)→ 停插件 → pi 退出
|
|
@@ -98,7 +102,6 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
|
|
|
98
102
|
| --- | --- | --- |
|
|
99
103
|
| `enabled` | `true` | `false` 时该工作区禁用扩展 |
|
|
100
104
|
| `watch.autoStart` | `true` | `false` 时注册工具但不建连接,需 `/onlyne connect` |
|
|
101
|
-
| `idleReminders` | `2` | 一次空闲期内重发任务的次数上限(§4);`0` 表示第一次空闲就判失败 |
|
|
102
105
|
|
|
103
106
|
文件缺失即取各自默认值。文件格式错误时打印一行警告,并保留默认值:一个笔误不该静默关掉一个
|
|
104
107
|
role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为 generate 期模板提示),所以
|
|
@@ -115,7 +118,7 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
|
|
|
115
118
|
| --- | --- | --- |
|
|
116
119
|
| `register` | 始终 | `welcome` 之后发 `session_register{session_id, task_id, generation, pid, title}` |
|
|
117
120
|
| `report` | 始终 | `report.ready` / `report.heartbeat` / `report.complete` |
|
|
118
|
-
| `inject` | `pi.sendUserMessage` 存在 |
|
|
121
|
+
| `inject` | `pi.sendUserMessage` 存在 | 投递以 `assign{…, text}` 到达,`text` 原样注入为 pi user message |
|
|
119
122
|
| `recycle` | 始终 | 收到 `recycle` 先补终态,再停插件并让 pi 退出 |
|
|
120
123
|
|
|
121
124
|
缺了某个 pi API 时会怎样,宿主怎么应对:
|
|
@@ -123,8 +126,8 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
|
|
|
123
126
|
| 缺失项 | 探测时机 | 行为 |
|
|
124
127
|
| --- | --- | --- |
|
|
125
128
|
| `registerTool`(老 pi) | `session_start` | 不注册任何工具;协议通路不受影响,`/onlyne status` 仍可用 |
|
|
126
|
-
| `sendUserMessage` | `session_start` | capability 里去掉 `inject`,宿主改走 `config_get{key:"stdin
|
|
127
|
-
| `
|
|
129
|
+
| `sendUserMessage` | `session_start` | capability 里去掉 `inject`,宿主改走 `config_get{key:"stdin:<投递文本>"}`,插件用剩余的注入通道投递 |
|
|
130
|
+
| `before_agent_start` 没有 `sections` | 每次 run 保护性检测 | role prose 进不了指令层——这次 run 没有可供写 section 的对象;stderr 打一行说明,投递文本照常到达 |
|
|
128
131
|
| `appendEntry` | `session_start` | 不再写 `onlyne-assign` / `onlyne-complete` 会话条目 |
|
|
129
132
|
| `ui.setStatus` | 调用点保护 | 跳过 footer 状态行 |
|
|
130
133
|
| `ui.setWidget` | 调用点保护 | 日常通知继续走 footer 状态行与 `[pi-onlyne]` stderr 行 |
|
|
@@ -136,58 +139,62 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
|
|
|
136
139
|
|
|
137
140
|
## 3. 工具面
|
|
138
141
|
|
|
139
|
-
仅在 onlyne session
|
|
142
|
+
仅在 onlyne session 内注册,用 MCP 面所带的同一套 schema(`docs/v2-CONTRACT.md`
|
|
143
|
+
“3b's interface: the `tools` mount”):一份义务词汇,两个驱动共用。
|
|
144
|
+
|
|
145
|
+
每个结果只有一句话——send 与 handoff 说发给谁,complete 说结果。工具结果模型看得见,
|
|
146
|
+
插件自己的账本没有理由出现在里面。
|
|
140
147
|
|
|
141
148
|
### `onlyne_send{to, text, kind?, image?}`
|
|
142
149
|
|
|
143
150
|
经 `send` 帧提交一个 envelope。`kind: "note"`(默认)是自由文本,不带 `op_id`。
|
|
144
151
|
`kind: "task"` 是派人办事,因此带 `o-<uuid>` 幂等键和新生成的 `causality.task`。`image` 是
|
|
145
152
|
png/jpeg/gif/webp 的绝对路径:插件读出内容,base64 编码后挂成 `body.image`。核心限 2 MiB,
|
|
146
|
-
只收四种 mime
|
|
153
|
+
只收四种 mime。结果是 `sent to <role>`。
|
|
147
154
|
|
|
148
|
-
### `onlyne_complete{outcome
|
|
155
|
+
### `onlyne_complete{outcome, summary, details?, files?}`
|
|
149
156
|
|
|
150
|
-
显式结束当前任务,`outcome`
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
显式结束当前任务,`outcome` 就是 proto 的 `Outcome`:`done`、`failed`、`cancelled` 或
|
|
158
|
+
`blocked`。`summary` 是展示用的一行,原样成为 ledger 的 `head`:空白折叠成单行,截到
|
|
159
|
+
200 字符;`summary` 为空时不带展示行,head 退回最后一段 assistant 文本。`details` 是完整结果,
|
|
160
|
+
`files` 是它点名的绝对路径:两者原样搭在 `report.complete` 帧上,也正是下游与发起方收到的东西,
|
|
161
|
+
上限由 client 把守,超限正文由 client 用自己的句子拒绝(契约 §3c)。这一调用同时结束所在
|
|
162
|
+
session 的进程。client 应答完 completion 报告(见 §4)之后,插件通过 `ctx.shutdown()` 让 pi
|
|
163
|
+
退出。pi 0.85.1 没有 tool-result `terminate` 处理。结果是 `reported <outcome>`。
|
|
157
164
|
|
|
158
165
|
### `onlyne_handoff{to, text, image?}`
|
|
159
166
|
|
|
160
167
|
把本会话手上的任务交给家族的下一跳。插件发一个 `handoff` 帧,帧里点名本会话当前持有的任务,
|
|
161
168
|
宿主据此为 `to` 铸一个该家族的子任务:子任务把本任务记为 `parent_task`,hop 加一,家族 id、
|
|
162
|
-
hop 预算、origin、deadline 与 labels
|
|
163
|
-
|
|
164
|
-
|
|
169
|
+
hop 预算、origin、deadline 与 labels 一并随行。结果是 `handed on to <role>`:子任务 id 与 hop
|
|
170
|
+
是 ledger 里的行,不是模型要读回来的东西。client 拒绝时以工具错误原样抛出。`image` 与 send
|
|
171
|
+
工具同一含义:png/jpeg/gif/webp 图片的绝对路径。hop 与
|
|
172
|
+
hop 预算留在 envelope 的 `causality` 里,属于协议数据:投递文本由 client 自己渲染(发送方、
|
|
173
|
+
正文、附件路径),本插件原样注入。`onlyne_send{kind: "task"}` 是触达 role 的另一条路:
|
|
165
174
|
那条 envelope 开一个新家族,hop 从 0 起。
|
|
166
175
|
|
|
167
176
|
## 4. outcome 判定规则
|
|
168
177
|
|
|
169
|
-
`onlyne_complete` 是通向 `done` 的唯一路径。插件每个任务只发一次 completion
|
|
178
|
+
`onlyne_complete` 是通向 `done` 的唯一路径。插件每个任务只发一次 completion,取以下三者的先到者:
|
|
170
179
|
|
|
171
|
-
1. **`onlyne_complete`** —— 模型给显式 outcome
|
|
172
|
-
任务的第二次 completion 被拒(不重报)。`
|
|
180
|
+
1. **`onlyne_complete`** —— 模型给显式 outcome:`done`、`failed`、`cancelled` 或 `blocked`。同一
|
|
181
|
+
任务的第二次 completion 被拒(不重报)。`summary` 非空时即 head,原样写出。
|
|
173
182
|
2. **turn 出错** —— turn 以 provider 错误告终(`stopReason: "error"`)。这本身就是证据,插件立即
|
|
174
|
-
报 `failed`,错误信息当 head
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
after <n> idle reminders`),并像任何一次 completion 一样退出 session。
|
|
178
|
-
4. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
|
|
183
|
+
报 `failed`,错误信息当 head。上报本身要等到 pi 正在等待输入时才发出,所以承载它的那次心跳
|
|
184
|
+
陈述的是 session 真实的阶段。
|
|
185
|
+
3. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
|
|
179
186
|
插件并退出 pi。
|
|
180
187
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
干净结束、却没有 completion 的 turn 在这里不结算任何东西。那条规则归 client
|
|
189
|
+
(`docs/v2-CONTRACT.md` 的 “3c. One turn-end rule”):它数这类结束、把自己的一句话作为 `nudge`
|
|
190
|
+
发来,并决定一次始终不上报的投递会变成什么。插件把那句话交给 pi,并且只回答自己交出去了,
|
|
191
|
+
于是措辞、计数与结算都只有一个主人,而不是两个。注入的消息还没跑过任何 turn 的任务,无论结算
|
|
192
|
+
信号说什么都不动:现在就报终态,等于声称干过一件没发生过的活。
|
|
186
193
|
|
|
187
194
|
`head` 恒为单行、上限 200 字符,与 client 写入 `out_head` 和回执携带的内容一致。每个任务的 head
|
|
188
|
-
只有一个来源:显式 `onlyne_complete`
|
|
189
|
-
|
|
190
|
-
|
|
195
|
+
只有一个来源:显式 `onlyne_complete` 带 `summary` 时就是它,否则是出错 turn 报的错误。最后一段
|
|
196
|
+
assistant 文本只是 `summary` 完全缺失的 `onlyne_complete` 的退路——调用之后再说的话顶不掉调用
|
|
197
|
+
交出的内容,此外没有任何东西读它。
|
|
191
198
|
|
|
192
199
|
报出去的 completion 会结束所在 session 的进程。`report.complete` 以请求形式发出,client 只有
|
|
193
200
|
在结算 session 行、ack 掉投递、并写好 `Completion` envelope 之后才应答,插件就在这个应答处
|
|
@@ -211,73 +218,23 @@ steer 或 follow-up 消息、重试、压缩,以及被后台任务扩展移出
|
|
|
211
218
|
|
|
212
219
|
后台任务扩展改变了这个问题。`bg_run` 与同类工具立即返回,工作继续在子进程里跑,于是 pi 在任务
|
|
213
220
|
仍在进行时就等待输入。插件通过该扩展注册的工具认出它,再向它的 EventBus 服务查存活任务列表;
|
|
214
|
-
处于 `running` 的任务会把 session 按在 `running`
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
阶梯算的是一次空闲期,不是任务的一生。session 自己跑起来的任何一轮都把计数清零,于是恢复运行、
|
|
218
|
-
继续干活之后,上限重新开始;阶梯自己的提醒唤醒的那一轮属于该提醒所属的空闲期,上限依然能达到。
|
|
219
|
-
|
|
220
|
-
## 5. 接力守卫
|
|
221
|
-
|
|
222
|
-
会话可以一件活都没交出去,就把 `done` 报掉。守卫堵的就是这个事故:一个 bench 会话边叙述进度边
|
|
223
|
-
调 `onlyne_complete`,四个 todo 一个没动,下游 writer 永远等一条从未发出的接力。判据只是投递
|
|
224
|
-
事实——某个 role 有没有被触达——绝不看发出去的文本长什么样、写得好不好。
|
|
225
|
-
|
|
226
|
-
策略文件放在插件自己的 `package.json` 旁边,因此随 generate 出的工作区一起被带进去:生成的工作
|
|
227
|
-
区里是 `<ws>/.onlyne/agent/onlyne-agent-pi/relay.toml`,手工安装则是插件目录下的 `relay.toml`。
|
|
228
|
-
|
|
229
|
-
```toml
|
|
230
|
-
relay_required = ["writer"] # 这些 role 必须收到过接力
|
|
231
|
-
relay_required_count = 2 # ……或至少这么多个不同的下游 role
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
两个键同时存在时以 `relay_required` 为准。
|
|
235
|
-
|
|
236
|
-
策略属于 spec,不属于 vendor 目录。`onlyne generate --force` 会重写本插件被拷进去的那份副本,
|
|
237
|
-
连带抹掉手写的 `relay.toml`;所以在 `[[client]]` 条目里写一次,client 就会把它注入到它拉起的
|
|
238
|
-
每一个 session 进程:
|
|
239
|
-
|
|
240
|
-
```toml
|
|
241
|
-
[[client]]
|
|
242
|
-
role = "planner"
|
|
243
|
-
relay_required = ["writer"] # 这些 role 必须收到过接力
|
|
244
|
-
relay_count = 2 # ……或至少这么多个不同的下游 role
|
|
245
|
-
```
|
|
221
|
+
处于 `running` 的任务会把 session 按在 `running` 上,插件在轮次结束时欠下的那份报告——它自己
|
|
222
|
+
目睹的失败——也要等它结束。没有装这个扩展的 session 没有工具可认、没有查询,也没有东西要等。
|
|
246
223
|
|
|
247
|
-
|
|
248
|
-
`ONLYNE_RELAY_COUNT`(数量,十进制)就是 client 按上面的条目填进去的两个变量;只有环境变量
|
|
249
|
-
一个都没给出策略时,才去读 `package.json` 旁边的 `relay.toml`;两者都没有 = 无守卫。spec 两个键
|
|
250
|
-
都写时 client 两个变量都注入,仍然以名单为准。手写的 `relay.toml` 仍是手工安装的逃生门——服务
|
|
251
|
-
那些 spec 里根本没写策略的机器——被环境变量盖住的文件则完全不参与。设了但解析不了的变量,会在
|
|
252
|
-
stderr 告警并忽略,把机会让回文件。
|
|
253
|
-
|
|
254
|
-
| | |
|
|
255
|
-
| --- | --- |
|
|
256
|
-
| 默认 | 两个来源都没给策略 = 无守卫,completion 路径与守卫存在之前逐字节相同 |
|
|
257
|
-
| 判据材料 | 本会话自己成功 `onlyne_send` 触达过的 role,`note` 与 `task` 都算;被 client 拒掉的 envelope 不算 |
|
|
258
|
-
| 拒绝 | `onlyne_complete` 抛 `onlyne: relay guard: missing handoff to: writer (…)`,点名缺哪条边、怎么解除 |
|
|
259
|
-
| 拒绝之后 | 不上报、不排队、不 detach:session 仍然挂着,补上接力后同一次调用即可落地 |
|
|
260
|
-
| 名单模式 | 名单里每个 role 都要字面出现在已投递集合里 |
|
|
261
|
-
| count 模式 | 数不同的下游 role;发给本 role 自己、或回指派活的上游,都不算一个 |
|
|
262
|
-
| 作用域 | 本会话自己的投递,仅进程内存:重连不丢,会话重启从空开始,不去猜上一个进程发过什么 |
|
|
263
|
-
| 豁免 | `force: true` 加非空 `reason`;只在守卫拒绝时才起作用 |
|
|
264
|
-
| 审计 | 被豁免的 completion,ledger head 以 `relay-guard-forced: <reason>` 开头;调用带了 `text` 时紧接其后 |
|
|
265
|
-
| 不管的路 | 插件不经过模型就报出的终态:turn 出错、idle 阶梯用尽,以及 `recycle{outcome}` |
|
|
266
|
-
|
|
267
|
-
`relay.toml` 是 TOML 的封闭子集:扁平的 `key = value` 行、上面两个键、单行双引号字符串数组、
|
|
268
|
-
`#` 注释。子集之外一律 stderr 告警并忽略。它刻意不放 `.onlyne/config.toml`:client 以
|
|
269
|
-
`deny_unknown_fields` 解析那个文件,插件往里加键会让 client 直接起不来。
|
|
270
|
-
|
|
271
|
-
没有策略时,`force` 与 `reason` 两个参数是惰性的。
|
|
272
|
-
|
|
273
|
-
## 6. 协议说明与偏差
|
|
224
|
+
## 5. 协议说明与偏差
|
|
274
225
|
|
|
275
226
|
下面每条要么是对 `PROTOCOL.md` 的明确解读,要么是在实际 client 上实测到的行为。
|
|
276
227
|
|
|
277
228
|
- **report 序号基址。** 插件自己的 `report` 序号从 1000 起,不是 1。client 把自身的派发事件
|
|
278
229
|
(`created`、资源 attach、`ready`)写进同一个 `(generation, seq)` 水位,reducer 会静默丢弃
|
|
279
|
-
水位及以下的报告(`crates/onlyne-
|
|
280
|
-
|
|
230
|
+
水位及以下的报告(`crates/onlyne-client/src/reconcile/`),所以从 1 起会丢掉最初的观测。
|
|
231
|
+
**一个插件一个计数器:** 会话持有的每个任务都共用同一条 `report` 序号发心跳 —— 因为 client
|
|
232
|
+
会在同一个任务的两次心跳之间,为该行自己的事件取 `row.seq + 1`;若每个任务每轮只推进一格,
|
|
233
|
+
心跳正好撞在那个数上被当作 stale 丢掉。闸门是按任务行判的,所以每条任务记录还带着自己上一次
|
|
234
|
+
上报的 `seq`(`task.lastSeq`,`/onlyne status` 里以 `taskSeqs` 呈现),新分配的序号会被抬到它
|
|
235
|
+
之上:`A@1001、B@1002、A@1003` 才是正确的形状,任何任务都不会拿到它的行已经接受过的 seq。
|
|
236
|
+
心跳轮次也绝不重叠:一轮还在写时收到的心跳请求会并进这一轮,只多做一遍,而不是对同一刻再快照
|
|
237
|
+
一次。其余版本语义与规范一致。
|
|
281
238
|
- **`observed` 是完整的 `Observation`。** `report.heartbeat` 携带整个合法状态元组
|
|
282
239
|
(`version`、`generation_live`、`isolate_after`、`terminate_after`、`mismatch_count`、
|
|
283
240
|
`agent`、`delivery`、`resource`、`recovery`),不是
|
|
@@ -295,17 +252,17 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
295
252
|
原因把该字段写成 `skip_serializing_if` 缺省。
|
|
296
253
|
- **`probe` 用一条 heartbeat 应答**,对应 `PROTOCOL.md` 里 “`probe` declares fresh resource
|
|
297
254
|
observations”。
|
|
298
|
-
- **`config_get` 只有当键以 `stdin:`
|
|
299
|
-
`inject`
|
|
255
|
+
- **`config_get` 只有当键以 `stdin:` 开头时按投递文本处理**,这正是 `PROTOCOL.md` 为无
|
|
256
|
+
`inject` 插件记录的重载:该键携带的是与 `assign` 的 `text` 相同的已渲染字节,原样注入。
|
|
257
|
+
其他键记日志后忽略,绝不误读。
|
|
300
258
|
- **`frame_too_large` / `bad_frame`**:超限正文在写出任何字节之前就被拒;帧错误关闭连接并重
|
|
301
|
-
连。帧一旦损坏无法重新同步,这与 `crates/onlyne-
|
|
259
|
+
连。帧一旦损坏无法重新同步,这与 `crates/onlyne-wire/src/frame.rs` 的结论一致。
|
|
302
260
|
- **投递按 envelope id 幂等,任务不按 id 一次性使用**:去重键是 envelope id。同一条投递重复
|
|
303
261
|
到达只注入一次,ack 带 `reason: "duplicate"`;正在运行的任务收到新 envelope,会作为新消息
|
|
304
|
-
|
|
305
|
-
client 每条 envelope 都发新 uuid,所以 `duplicate` 只在真正的重投上生效。
|
|
262
|
+
注入同一个会话。client 每条 envelope 都发新 uuid,所以 `duplicate` 只在真正的重投上生效。
|
|
306
263
|
|
|
307
264
|
- **pane 绑定(Orca tab)。** 在 Orca pane 里,插件在每个 heartbeat 上报自己跑在哪:报告
|
|
308
|
-
`Observation` 里的 `observed.host.orca.pane_key`(`crates/onlyne-
|
|
265
|
+
`Observation` 里的 `observed.host.orca.pane_key`(`crates/onlyne-client/src/host.rs`),环境
|
|
309
266
|
报得出时还带上 `tab_id` / `leaf_id` 和终端的 `handle`。这个绑定是**继承**来的,不是猜的:Orca
|
|
310
267
|
pane 会把自己那四个 `ORCA_PANE_KEY` / `ORCA_TAB_ID` / `ORCA_LEAF_ID` / `ORCA_TERMINAL_HANDLE`
|
|
311
268
|
导出给它启动的命令(2026-09-11 实测,Orca 1.4.198),而 client 会把自己的环境继续传给
|
|
@@ -317,16 +274,14 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
317
274
|
被碰。这既让 `integrations/orca-plugin` 能不读任何路径就把 tab 轴收窄到真会话,也让
|
|
318
275
|
supervisor 在会话 *结束之后*仍然说得出它跑在哪:`report.complete` 会把 `host` 带过去。
|
|
319
276
|
|
|
320
|
-
##
|
|
277
|
+
## 6. 配置项
|
|
321
278
|
|
|
322
279
|
| 环境变量 | 必需 | 作用 |
|
|
323
280
|
| --- | --- | --- |
|
|
324
281
|
| `ONLYNE_ROLE` | 是 | 挂载的 role |
|
|
325
282
|
| `ONLYNE_SESSION_ID` | 是 | 挂载的 session id;当前 client 中 session_id 等于 task_id |
|
|
326
283
|
| `ONLYNE_TASK_ID` | 是 | 本进程服务的任务;驱动 `session_register` 与首条 `ready` |
|
|
327
|
-
| `ONLYNE_SOCKET` | 否 | client 为该工作区实际服务的 socket 路径;凡 client
|
|
328
|
-
| `ONLYNE_RELAY_REQUIRED` | 否 | 该 role 在 spec 里的 `relay_required`,逗号分隔:守卫的名单模式(§5) |
|
|
329
|
-
| `ONLYNE_RELAY_COUNT` | 否 | 该 role 在 spec 里的 `relay_count`:守卫的 count 模式,只在名单为空时起作用(§5) |
|
|
284
|
+
| `ONLYNE_SOCKET` | 否 | client 为该工作区实际服务的 socket 路径;凡 client 拉起的会话进程都会带上。变量未设置时,插件自己去运行目录读注册文件(`<digest>.json`),挑出 `root` 就是本工作区的那个 client |
|
|
330
285
|
| `ORCA_PANE_KEY` | 否 | 本进程跑在哪(`<tab_id>:<leaf_id>`),每个 heartbeat 以 `observed.host.orca.pane_key` 上报;不在 Orca pane 里时未设置,这也是该字段缺席的原因 |
|
|
331
286
|
| `ORCA_TAB_ID` / `ORCA_LEAF_ID` | 否 | pane 的两个 id;只设了 pane key 时插件会自己解析 |
|
|
332
287
|
| `ORCA_TERMINAL_HANDLE` | 否 | 终端 handle,随 pane key 一起上报为 `host.orca.handle`,也是 `orca terminal switch` 要的那个值 |
|
|
@@ -334,23 +289,22 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
334
289
|
值得记住的常量:插件每 10 秒发一次心跳(`heartbeat_timeout_ms` 是 30 秒),`hello` 最多等
|
|
335
290
|
5 秒,单次请求超时 30 秒,重连按 1/2/4/8/16/30 秒阶梯退避。
|
|
336
291
|
|
|
337
|
-
|
|
338
|
-
`
|
|
339
|
-
|
|
340
|
-
没带路径时才读,§8)。
|
|
292
|
+
插件自己读一个文件:`<cwd>/.pi/onlyne.json`(开关,§1)。
|
|
293
|
+
另一处读取属于机器而不是工作区:`ONLYNE_SOCKET` 未设置时,插件遍历机器级运行目录里的
|
|
294
|
+
client 注册文件,找出写下本工作区的那个(§7)。
|
|
341
295
|
|
|
342
|
-
##
|
|
296
|
+
## 7. 故障排查
|
|
343
297
|
|
|
344
298
|
| 现象 | 原因 | 检查 |
|
|
345
299
|
| --- | --- | --- |
|
|
346
300
|
| 看不到 `[pi-onlyne] session …` | 三个环境变量缺一,或 `enabled` 为 false | `env \| grep ONLYNE_`;`cat .pi/onlyne.json` |
|
|
347
|
-
| `socket
|
|
348
|
-
|
|
|
301
|
+
| `socket unresolved: onlyne: no client is registered for <workspace> …` | 该工作区没有 `onlyne-client run`,所以运行目录里没有哪个注册文件的 `root` 是这棵树 | 起 client,或 `onlyne-client status`;这条消息会点出运行目录,以及它实际读到的每个注册文件 |
|
|
302
|
+
| `socket unresolved: … N clients there name runtime pi … ambiguous` | 有多个已注册的 client 都在跑 pi 会话,而它们的 root 都不包含本工作区,于是没有唯一可拨的 client | 用 `ONLYNE_SOCKET` 显式指定 socket,或为本工作区起 client |
|
|
303
|
+
| `socket error: connect ENOENT <路径>` | 消息里的路径没人 bind:注入它的那个 client 已经停了 | `onlyne-client status` 看它当前服务的 socket,再看 client 日志里带 `socket = <路径>` 的那行 |
|
|
349
304
|
| 反复 `reconnecting in 4000ms` | client 已停或 socket 被替换 | `onlyne --server-root … roles` |
|
|
350
305
|
| `ready refused: internal: unknown session for …` | 插件为 client 从未暂存的任务报了 ready(手工起 pi 时的正常现象) | 让 client 拉起 pi,而不是手工起 |
|
|
351
306
|
| `assign` 一直不来 | client 的 `session_command` 没能拉起 pi,或 `inject` 被降级 | client 日志里的 spawn 行;`/onlyne status` 看能力集 |
|
|
352
|
-
| ledger 停在 `in_flight` | 还没有 completion:没跑过 turn
|
|
353
|
-
| `onlyne_complete` 回答 `relay guard: missing handoff to: …` | 工作区的 spec(或顶替它的 `relay.toml`)点名了一个本会话从未触达的 role | 日常通知显示在 `onlyne` 面板;stderr 保留 `relay guard from …` 等拒绝、socket 错误、超时与帧错误;`required=…` 说明策略;`relay guard: missing handoff …` 列出已投递集合 |
|
|
307
|
+
| ledger 停在 `in_flight` | 还没有 completion:没跑过 turn(注入的消息尚未执行),或 turn 结束时没有 completion,而 client 还没结算这次投递 | pi session 文件里的 `onlyne-assign` 条目和其后注入的 `nudge` 句子;`/onlyne status` 看任务与阶段 |
|
|
354
308
|
| `hello` 后立刻 `forbidden` / 断连 | mount role 与 client 的 role 不一致 | `hello.args.mount.role` 对该工作区的 role |
|
|
355
309
|
| `frame_too_large` | 正文超过 8 MiB | 只会由超限的出站图片触发;上限来自核心 |
|
|
356
310
|
| 工具缺失 | 该 pi 版本没有 `pi.registerTool` | `/onlyne status`;对照上面的能力表 |
|
|
@@ -358,14 +312,14 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
358
312
|
| supervisor 看板一个 tab 都不列 | 没有 live session 上报过 pane:适配器版本早于这条上报,或这个 pi 不在 Orca pane 里 | `onlyne --server-root … sessions --json` 看 `projection.observed.host.orca.pane_key`;在 pane 里跑 `env \| grep ORCA_` |
|
|
359
313
|
|
|
360
314
|
`/onlyne status` 打印实时状态(`connected`、`socket`、`role`、`sessionId`、`generation`、
|
|
361
|
-
`agentState`、`tasks`、`
|
|
362
|
-
`/onlyne disconnect` 手工开合连接。
|
|
315
|
+
`agentState`、`seq`、`taskSeqs`、`tasks`、`pendingCompletions`、`lastError` 与计数器);
|
|
316
|
+
`/onlyne connect` / `/onlyne disconnect` 手工开合连接。
|
|
363
317
|
|
|
364
|
-
##
|
|
318
|
+
## 8. 开发与验证
|
|
365
319
|
|
|
366
320
|
```bash
|
|
367
321
|
cd plugins/onlyne-agent-pi
|
|
368
|
-
node --test src/*.test.mjs # 帧编解码、协议词汇、agent
|
|
322
|
+
node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、socket 路径
|
|
369
323
|
```
|
|
370
324
|
|
|
371
325
|
`src/agent.live.test.mjs` 只在 `target/debug/onlyne-client` 与 `onlyne-server` 存在时运行。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-onlyne",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
"homepage": "https://github.com/dbydd/onlyne/tree/main/plugins/onlyne-agent-pi#readme",
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
|
-
"onlyne.json.example"
|
|
19
|
-
"relay.toml.example"
|
|
18
|
+
"onlyne.json.example"
|
|
20
19
|
],
|
|
21
20
|
"keywords": [
|
|
22
21
|
"pi-package",
|
package/src/activity.test.mjs
CHANGED
|
@@ -52,12 +52,6 @@ test("hostile input stays inside render bounds", () => {
|
|
|
52
52
|
for (const line of lines) assert.ok(Array.from(line).length <= MAX_WIDTH, line);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("identical state gives identical output", () => {
|
|
56
|
-
const activity = createActivity({ clock }).set({ role: "planner", connection: "connected" });
|
|
57
|
-
activity.note("in", "build it");
|
|
58
|
-
assert.deepEqual(activity.lines(), activity.lines());
|
|
59
|
-
});
|
|
60
|
-
|
|
61
55
|
test("default render shows the newest SHOW_EVENTS events", () => {
|
|
62
56
|
const activity = createActivity({ clock });
|
|
63
57
|
for (let index = 0; index < SHOW_EVENTS + 4; index += 1) activity.note("state", `event ${index}`);
|
package/src/agent.live.test.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { test } from "node:test";
|
|
20
20
|
|
|
21
21
|
import { OnlyneAgent } from "./agent.mjs";
|
|
22
|
+
import { resolveSocketPath, socketPath } from "./socket.mjs";
|
|
22
23
|
|
|
23
24
|
const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
|
24
25
|
const BIN_DIR = join(REPO_ROOT, "target", "debug");
|
|
@@ -50,17 +51,21 @@ test("a real onlyne-client answers hello with a welcome", { skip: !hasBinaries }
|
|
|
50
51
|
execFileSync(CLIENT, ["init", "--workspace", workspace, "--role", "planner", "--server-root", serverRoot], {
|
|
51
52
|
stdio: "pipe",
|
|
52
53
|
});
|
|
53
|
-
|
|
54
|
+
// v2 binds the adapter socket in the machine-level runtime directory, so the
|
|
55
|
+
// case pins one of its own: the client inherits the override and the plugin
|
|
56
|
+
// derives the same path from the workspace root (`socket.mjs`).
|
|
57
|
+
const env = { ...process.env, ONLYNE_RUNTIME_DIR: join(tmp, "runtime") };
|
|
58
|
+
const child = spawn(CLIENT, ["run", "--workspace", workspace], { stdio: ["ignore", "pipe", "pipe"], env });
|
|
54
59
|
let clientLog = "";
|
|
55
60
|
child.stdout.on("data", (chunk) => { clientLog += chunk; });
|
|
56
61
|
child.stderr.on("data", (chunk) => { clientLog += chunk; });
|
|
57
62
|
|
|
58
|
-
const
|
|
63
|
+
const served = socketPath(workspace, env);
|
|
59
64
|
const surface = {
|
|
60
65
|
available: { wakeUser: true },
|
|
61
66
|
calls: [],
|
|
62
67
|
wakeUser(text) { this.calls.push(text); return true; },
|
|
63
|
-
|
|
68
|
+
roleProse: () => true,
|
|
64
69
|
customEntry: () => true,
|
|
65
70
|
status: () => {},
|
|
66
71
|
welcome: () => {},
|
|
@@ -70,7 +75,7 @@ test("a real onlyne-client answers hello with a welcome", { skip: !hasBinaries }
|
|
|
70
75
|
};
|
|
71
76
|
const logs = [];
|
|
72
77
|
const agent = new OnlyneAgent({
|
|
73
|
-
socketPath,
|
|
78
|
+
socketPath: served,
|
|
74
79
|
cwd: workspace,
|
|
75
80
|
role: "planner",
|
|
76
81
|
sessionId: SESSION_ID,
|
|
@@ -80,7 +85,12 @@ test("a real onlyne-client answers hello with a welcome", { skip: !hasBinaries }
|
|
|
80
85
|
heartbeatMs: 60_000,
|
|
81
86
|
});
|
|
82
87
|
try {
|
|
83
|
-
await waitFor(() => existsSync(
|
|
88
|
+
await waitFor(() => existsSync(served));
|
|
89
|
+
// The hand-started case: no `ONLYNE_SOCKET`, so the plugin reads the
|
|
90
|
+
// registration the client published in the runtime directory. One tree,
|
|
91
|
+
// two derivations — the client's from its own root, the plugin's from the
|
|
92
|
+
// cwd — and the case fails here if they disagree by a byte.
|
|
93
|
+
assert.equal(resolveSocketPath({ ONLYNE_RUNTIME_DIR: env.ONLYNE_RUNTIME_DIR }, workspace), served);
|
|
84
94
|
agent.start();
|
|
85
95
|
await waitFor(() => agent.status().connected, { timeoutMs: 15_000 });
|
|
86
96
|
|