pi-onlyne 2.1.0 → 2.2.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 CHANGED
@@ -14,6 +14,11 @@ Outside an onlyne session the extension is inert. The client injects `ONLYNE_ROL
14
14
  (`crates/onlyne-client/src/session/dispatch.rs`). With any of the three missing, this is an
15
15
  ordinary pi session: the plugin registers nothing and opens nothing.
16
16
 
17
+ Only the session whose own id equals `ONLYNE_SESSION_ID` mounts. A pi process runs more
18
+ than one session — a `task` subagent's sidechain, `/new` — and each of them loads this
19
+ extension with the same inherited environment, so a session that is not the bound one
20
+ stays inert and every onlyne tool it calls is refused.
21
+
17
22
  ```
18
23
  pi session (spawned by onlyne-client)
19
24
  │ env: ONLYNE_ROLE / ONLYNE_SESSION_ID / ONLYNE_TASK_ID
@@ -157,6 +162,14 @@ Each result is one plain sentence — the recipient for a send or a handoff, the
157
162
  for a completion. A tool result is model-visible, and nothing about the plugin's own
158
163
  bookkeeping belongs in it.
159
164
 
165
+ The three tools belong to the session that mounted them. A pi process runs more than one
166
+ session — a `task` subagent's sidechain, `/new` — and every one of them loads this
167
+ extension with the same inherited environment, so a call from a session whose id is not
168
+ `ONLYNE_SESSION_ID` is refused with a sentence naming both ids instead of settling the
169
+ parent's task. A pi that does not expose a session id (there is no `getSessionId` in
170
+ `ctx.sessionManager`) is allowed through: the env-bound mount is still the only one that
171
+ connected.
172
+
160
173
  ### `onlyne_send{to, text, kind?, image?}`
161
174
 
162
175
  Sends one envelope on the `send` frame. `kind: "note"` (the default) is free text and
@@ -178,7 +191,10 @@ receive, with the client holding the ceiling and refusing an oversize body with
178
191
  sentence (§3c of the contract). The call also ends the session's process: once the client
179
192
  has acknowledged the completion report (§4), the plugin asks pi to shut down through
180
193
  `ctx.shutdown()`. pi 0.85.1 has no tool-result `terminate` handling. The result is
181
- `reported <outcome>`.
194
+ `reported <outcome>` — or, when a client that had already settled the task answers the
195
+ report with its own record, `task already settled as <state> at <settled_at>: <head> —
196
+ this call changed nothing`, which is the one case where the call did not cause the
197
+ outcome it asked for.
182
198
 
183
199
  ### `onlyne_handoff{to, text, image?}`
184
200
 
@@ -196,33 +212,36 @@ of its own at hop 0.
196
212
 
197
213
  ## 4. Outcome rules
198
214
 
199
- `onlyne_complete` is the only path to `done`. The plugin sends one completion per task,
200
- at the first of these events:
215
+ `onlyne_complete` is the only path to `done`, and it is the plugin's one completion path.
216
+ The plugin sends one completion per task, at the first of these events:
201
217
 
202
218
  1. **`onlyne_complete`** — the model gives an explicit outcome: `done`, `failed`,
203
219
  `cancelled` or `blocked`. A later completion for the same task is refused (not
204
220
  re-reported). Its non-empty `summary` is the head.
205
- 2. **An errored turn** — the turn ended with a provider error (`stopReason: "error"`).
206
- That is proof on its own, so the plugin reports `failed` at once, with the error as
207
- the head. The report itself waits until pi is waiting for input, so the beat that
208
- carries it states a phase the session really has.
209
- 3. **`recycle{outcome}`** — the host is tearing the session down. The plugin settles an
221
+ 2. **`recycle{outcome}`** — the host is tearing the session down. The plugin settles an
210
222
  unsettled task with the host's outcome first, then stops and exits pi.
211
223
 
212
- A turn that ends cleanly without a completion settles nothing here. The client owns that
213
- rule (`docs/v2-CONTRACT.md`, "3c. One turn-end rule"): it counts the endings, sends its
214
- own sentence as a `nudge`, and decides what a delivery that never reports becomes. The
215
- plugin hands the sentence to pi and answers only that it did, so the wording, the count
216
- and the settlement have one owner rather than two. A task whose injected message has not
217
- run a turn is left alone whatever a settle signal says: reporting now would claim work
218
- that never happened.
224
+ A turn that ends without a completion settles nothing here, and neither does a turn that
225
+ ends with one. The endings belong to the client (`docs/v2-CONTRACT.md`, "3c. One
226
+ turn-end rule"): it counts them, sends its own sentence as a `nudge`, and decides what a
227
+ delivery that never reports becomes; its own sweeps own a session whose agent died. The
228
+ plugin hands the nudge to pi, answers only that it did, and reports only its phase and
229
+ the completion the model asked for — one owner for the wording, the count and the
230
+ settlement, rather than two. A task whose injected message has not run a turn is left
231
+ alone whatever a settle signal says: reporting now would claim work that never happened.
232
+
233
+ A turn that ended with a provider error (`stopReason: "error"`) is not a verdict either.
234
+ pi retries a retryable provider error and stays inside the same run, and the client's
235
+ nudge can restart a session a moment after it settles, so a report sent on the error
236
+ would race work that is still going. The error only supplies the head fallback described
237
+ below.
219
238
 
220
239
  `head` is a single line, capped at 200 characters; it matches what the client puts in
221
240
  `out_head` and what the receipt carries. Each task has one source for it: the `summary`
222
- of the explicit `onlyne_complete` call when that call carried one, or the error a failed
223
- turn reported. The last assistant text is the fallback for a call whose `summary` is
224
- empty — a sentence spoken after such a call cannot replace what the call handed over, and
225
- nothing else reads it.
241
+ of the explicit `onlyne_complete` call when that call carried one. A call whose `summary`
242
+ is empty falls back to what the session last said — the last assistant text, or the error
243
+ of a turn that failed. The error is only ever that fallback: it is never a completion, and
244
+ a sentence spoken after such a call cannot replace what the call handed over.
226
245
 
227
246
  A reported completion ends the session's process. `report.complete` goes out as a request,
228
247
  and the client answers it only after it has settled the session row, acked the delivery
@@ -231,6 +250,13 @@ outcome the socket could not carry is queued and flushed after the next `hello`,
231
250
  flush's answer is the handover that ends the process. A completion the host refused leaves
232
251
  the process running, so an exit never loses the task.
233
252
 
253
+ A client that had already settled this task answers the same request with
254
+ `{"already_settled": {state, settled_at, head}}` instead of `null`. The plugin hands that
255
+ record on rather than claiming an outcome it did not cause, and the tool result says so
256
+ (§3); on the reconnect flush, where no tool result exists to carry it, the record reaches
257
+ the `[pi-onlyne]` log. Either way the task stays settled: the client's row is the
258
+ authority.
259
+
234
260
  The completion is the plugin's last report, and a session that has completed one answers
235
261
  no further beat and no `probe`. The terminal agent state is the client's own write: the
236
262
  `detach` frame that follows retires the task-free session, and that path feeds
@@ -256,9 +282,9 @@ A background-task extension changes the question. `bg_run` and its siblings retu
256
282
  once and the work continues in a child process, so pi waits for input while the session's
257
283
  task is still in flight. The plugin recognises the extension by the tools it registered
258
284
  and asks its EventBus service for the live task list; a task in `running` status holds
259
- the session at `running`, and the one report the plugin owes at a turn end — the failure
260
- it witnessed itself — waits that out. On a session without the extension there is no tool
261
- to recognise, no query, and nothing to wait for.
285
+ the session at `running`, so the beat states a phase the session really has. No report of
286
+ the plugin's own waits on that: nothing is reported at a turn end any more. On a session
287
+ without the extension there is no tool to recognise, no query, and nothing to wait for.
262
288
 
263
289
  ## 5. Protocol notes and deviations
264
290
 
@@ -403,6 +429,8 @@ agent's own output lands in `<ws>/.onlyne/logs/session-<task>.log`.
403
429
 
404
430
  在一个 onlyne 会话之外,扩展不会执行任何操作。客户端会向其启动的每个进程注入 `ONLYNE_ROLE`、`ONLYNE_SESSION_ID` 和 `ONLYNE_TASK_ID`(`crates/onlyne-client/src/session/dispatch.rs`)。任一变量缺失时,这就是一个普通的 pi 会话:插件不注册任何内容,也不打开任何内容。
405
431
 
432
+ 只有自身会话 id 等于 `ONLYNE_SESSION_ID` 的会话才会挂载。一个 pi 进程会跑不止一个会话——`task` 子代理的 sidechain、`/new`——它们都带着同一份继承来的环境变量加载本扩展,因此不是被绑定会话的那个会话保持静默,它发出的每一次 onlyne 工具调用都会被拒绝。
433
+
406
434
  ```
407
435
  pi session (spawned by onlyne-client)
408
436
  │ env: ONLYNE_ROLE / ONLYNE_SESSION_ID / ONLYNE_TASK_ID
@@ -522,6 +550,8 @@ pi API 缺失时会发生什么,以及主机随后如何处理:
522
550
 
523
551
  每个结果只有一句平实的话——send 与 handoff 给出发往的角色,complete 给出结果。工具结果是模型可见的,插件自己的簿记没有理由出现在里面。
524
552
 
553
+ 这三个工具属于挂载它们的那个会话。一个 pi 进程会跑不止一个会话——`task` 子代理的 sidechain、`/new`——它们都会带着同一份继承来的环境变量加载本扩展;因此,当调用的会话 id 不等于 `ONLYNE_SESSION_ID` 时,调用会被拒绝,并给出同时点名两个 id 的一句话,而不是去结算父任务。若该 pi 不暴露会话 id(`ctx.sessionManager` 没有 `getSessionId`),调用照常放行:此时唯一连上 socket 的仍然是那个由环境变量绑定的挂载。
554
+
525
555
  ### `onlyne_send{to, text, kind?, image?}`
526
556
 
527
557
  通过 `send` 帧发送一个信封。`kind: "note"`(默认值)是自由文本,不携带 `op_id`。`kind: "task"` 将工作移交给一个角色,因此携带 `o-<uuid>` 幂等键和新的 `causality.task`。`image` 是 `png/jpeg/gif/webp` 文件的绝对路径:插件读取该文件,进行 base64 编码,并将其作为 `body.image` 附加。核心将该文件限制为 2 MiB,并接受四种 mime 类型。
@@ -530,7 +560,7 @@ pi API 缺失时会发生什么,以及主机随后如何处理:
530
560
 
531
561
  ### `onlyne_complete{outcome, summary, details?, files?}`
532
562
 
533
- 以明确结果结束当前任务——即 proto 的 `Outcome`:`done`、`failed`、`cancelled` 或 `blocked`。`summary` 是展示用的一行,原样成为账本的 `head`:空白折叠为一行,文本在 200 个字符处截断;`summary` 为空时不携带展示行,head 回退到最后一条助手文本。`details` 是完整结果,`files` 是它所点名的绝对路径:两者原样搭在 `report.complete` 帧上,也正是下游与发起方收到的东西,上限由客户端把守,超限正文由客户端用自己的句子拒绝(契约 §3c)。该调用也会结束会话进程:客户端确认完成报告后(见 §4),插件会通过 `ctx.shutdown()` 请求 pi 关闭。pi 0.85.1 没有工具结果的 `terminate` 处理。结果为 `reported <outcome>`。
563
+ 以明确结果结束当前任务——即 proto 的 `Outcome`:`done`、`failed`、`cancelled` 或 `blocked`。`summary` 是展示用的一行,原样成为账本的 `head`:空白折叠为一行,文本在 200 个字符处截断;`summary` 为空时不携带展示行,head 回退到最后一条助手文本。`details` 是完整结果,`files` 是它所点名的绝对路径:两者原样搭在 `report.complete` 帧上,也正是下游与发起方收到的东西,上限由客户端把守,超限正文由客户端用自己的句子拒绝(契约 §3c)。该调用也会结束会话进程:客户端确认完成报告后(见 §4),插件会通过 `ctx.shutdown()` 请求 pi 关闭。pi 0.85.1 没有工具结果的 `terminate` 处理。结果为 `reported <outcome>`;若客户端此前已经结算过该任务,它会用自己的记录作答,此时结果变成 `task already settled as <state> at <settled_at>: <head> — this call changed nothing`——这是调用并未造成它所请求的那个结果的唯一情形。
534
564
 
535
565
  ### `onlyne_handoff{to, text, image?}`
536
566
 
@@ -538,18 +568,21 @@ pi API 缺失时会发生什么,以及主机随后如何处理:
538
568
 
539
569
  ## 4. 结果规则
540
570
 
541
- `onlyne_complete` 是通向 `done` 的唯一路径。插件为每个任务发送一次完成报告,在以下事件中第一个发生时发送:
571
+ `onlyne_complete` 是通向 `done` 的唯一路径,也是插件唯一的完成路径。插件为每个任务发送一次完成报告,在以下事件中第一个发生时发送:
542
572
 
543
573
  1. **`onlyne_complete`**——模型给出明确结果:`done`、`failed`、`cancelled` 或 `blocked`。同一任务后续的完成调用会被拒绝,不会再次报告。其非空 `summary` 就是 head。
544
- 2. **出错的轮次**——该轮次以模型提供方错误结束(`stopReason: "error"`)。这本身即可证明出错,因此插件立即报告 `failed`,并将错误作为 head。报告本身要等到 pi 正在等待输入时才发出,因此承载它的那次心跳陈述的是会话真实的阶段。
545
- 3. **`recycle{outcome}`**——主机正在拆除会话。插件先使用主机给出的结果确定尚未确定状态的任务,然后停止并退出 pi。
574
+ 2. **`recycle{outcome}`**——主机正在拆除会话。插件先使用主机给出的结果确定尚未确定状态的任务,然后停止并退出 pi。
575
+
576
+ 没有完成就结束的轮次在这里不结算任何东西,以错误结束的轮次同样不结算。结束这件事归 client(`docs/v2-CONTRACT.md` 的「3c. One turn-end rule」):它统计这类结束、把自己的一句话作为 `nudge` 发来,并决定一次始终不上报的投递会变成什么;agent 已死的会话由它自己的清扫负责。插件把那句话交给 pi,并且只回答自己交出去了,同时只上报自己的阶段与模型主动要求的完成——措辞、计数与结算都只有一个拥有者,而不是两个。注入消息尚未跑过任何轮次的任务,无论结算信号说什么都不动:现在就上报等于宣称从未发生的工作已经完成。
546
577
 
547
- 正常结束、但没有完成的轮次在这里不结算任何东西。那条规则由 client 拥有(`docs/v2-CONTRACT.md` 的「3c. One turn-end rule」):它统计这类结束、把自己的一句话作为 `nudge` 发来,并决定一次始终不上报的投递会变成什么。插件把那句话交给 pi,并且只回答自己交出去了,于是措辞、计数与结算都只有一个拥有者,而不是两个。注入消息尚未跑过任何轮次的任务,无论结算信号说什么都不动:现在就上报等于宣称从未发生的工作已经完成。
578
+ 以提供方错误(`stopReason: "error"`)结束的轮次也不是结论:pi 会对可重试的提供方错误重试,并留在同一次运行里,而 client 的 nudge 可能在会话结算之后的一瞬间重新启动它,所以在这里发出报告只会与仍在进行的工作赛跑。错误只提供下面说的 head 回退。
548
579
 
549
- `head` 是单行文本,上限为 200 个字符;它与客户端写入 `out_head` 的内容以及回执携带的内容一致。每个任务的 `head` 只有一个来源:显式 `onlyne_complete` 调用携带 `summary` 时就是它,否则是失败轮次报告的错误。完全没有携带 `summary` 的调用,回退到最后一条助手文本;此类调用之后说出的句子不能替代调用已经交付的内容,也不会被其他任何内容读取。
580
+ `head` 是单行文本,上限为 200 个字符;它与客户端写入 `out_head` 的内容以及回执携带的内容一致。每个任务的 `head` 只有一个来源:显式 `onlyne_complete` 调用携带 `summary` 时就是它。完全没有携带 `summary` 的调用回退到会话最后说过的话——最后一条助手文本,或失败轮次留下的错误。错误永远只是这个回退:它本身从不构成完成,而此类调用之后说出的句子不能替代调用已经交付的内容。
550
581
 
551
582
  已报告的完成会结束会话进程。`report.complete` 以请求形式发出;客户端仅在完成会话记录的处理、确认投递并写入 `Completion` 信封后才回复。插件在该回复到达时请求 pi 关闭。套接字无法传输的结果会进入队列,并在下一次 `hello` 之后刷新;该次刷新收到的回复即为结束进程的交接。主机拒绝的完成会让进程继续运行,因此进程退出不会导致任务丢失。
552
583
 
584
+ 若客户端此前已经结算过该任务,它会对同一个请求回答 `{"already_settled": {state, settled_at, head}}` 而不是 `null`。插件把这个记录原样转交,而不是声称一个并非由本次调用造成的结果,工具结果也照此说明(见 §3);在重连补发那条路径上,没有工具结果可以承载它,记录会进入 `[pi-onlyne]` 日志。无论哪种情形任务都保持已结算:以客户端的那一行为准。
585
+
553
586
  完成报告是插件发出的最后一份报告;已经完成任务的会话不再回应心跳,也不再回应 `probe`。终态的 agent 维度由客户端自己写入:随后到达的 `detach` 会退役这个已无任务的会话,该路径会喂入 `AgentGone` 并发布这一行(`retire_idle_locked`,`crates/onlyne-client/src/session/dispatch/retire.rs`)。正在离开的进程不为自己声明阶段。
554
587
 
555
588
  ### 空闲判定
@@ -558,7 +591,7 @@ pi API 缺失时会发生什么,以及主机随后如何处理:
558
591
 
559
592
  插件向 pi 查询,而不是自行假设。`ctx.isIdle()` 回答是否还有运行、压缩或排队中的续跑,`ctx.hasPendingMessages()` 回答输入是否已经在路上;缺失或抛错的探针一律读作 `running`。`agent_settled` 通常是空闲声明落地的时刻,因为 pi 只在一次运行彻底结算之后才触发该事件;单个轮次的结束是另一个时刻。每 10 秒的心跳会在每次触发时重新向 pi 推导阶段,因此重新开始的运行不会留下过期的空闲读数。
560
593
 
561
- 后台任务扩展改变了这个问题。`bg_run` 及其同类工具立即返回,工作继续在子进程中运行,于是 pi 在会话任务仍在进行时等待输入。插件通过该扩展注册的工具识别它,并向它的 EventBus 服务查询存活任务列表;处于 `running` 状态的任务会让会话保持 `running`,插件在轮次结束时欠下的那份报告——它自己目睹的失败——也要等它结束。没有安装该扩展的会话没有可识别的工具、没有查询,也就没有可等待的东西。
594
+ 后台任务扩展改变了这个问题。`bg_run` 及其同类工具立即返回,工作继续在子进程中运行,于是 pi 在会话任务仍在进行时等待输入。插件通过该扩展注册的工具识别它,并向它的 EventBus 服务查询存活任务列表;处于 `running` 状态的任务会让会话保持 `running`,于是心跳陈述的是会话真实的阶段。插件自己没有任何报告要等它结束:轮次结束时已经不再上报任何东西。没有安装该扩展的会话没有可识别的工具、没有查询,也就没有可等待的东西。
562
595
 
563
596
  ## 5. 协议说明与差异
564
597
 
package/README.zh.md CHANGED
@@ -10,6 +10,10 @@ socket——机器级运行目录里的 `<digest>.sock`(`/tmp/onlyne-<uid>/`
10
10
  `ONLYNE_TASK_ID`(`crates/onlyne-client/src/session/dispatch.rs`);三者缺一,就是普通 pi session,
11
11
  插件不注册任何工具、不打开任何 socket。
12
12
 
13
+ 只有自身 session id 等于 `ONLYNE_SESSION_ID` 的那个 session 才会挂载。一个 pi 进程会跑不止一个
14
+ session——`task` 子代理的 sidechain、`/new`——它们都会带着同一份继承来的环境变量加载本扩展;
15
+ 因此不是被绑定 session 的那个保持静默,它发出的每一次 onlyne 工具调用都会被拒绝。
16
+
13
17
  ```
14
18
  pi session(由 onlyne-client spawn)
15
19
  │ 环境变量:ONLYNE_ROLE / ONLYNE_SESSION_ID / ONLYNE_TASK_ID
@@ -145,6 +149,12 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
145
149
  每个结果只有一句话——send 与 handoff 说发给谁,complete 说结果。工具结果模型看得见,
146
150
  插件自己的账本没有理由出现在里面。
147
151
 
152
+ 这三个工具属于挂载它们的那个 session。一个 pi 进程会跑不止一个 session——`task` 子代理的
153
+ sidechain、`/new`——它们都会带着同一份继承来的环境变量加载本扩展;因此当调用的 session id
154
+ 不等于 `ONLYNE_SESSION_ID` 时,调用会被拒绝,并给出同时点名两个 id 的一句话,而不是去结算父
155
+ 任务。若该 pi 不暴露 session id(`ctx.sessionManager` 没有 `getSessionId`),调用照常放行:
156
+ 此时唯一连上 socket 的仍然是那个由环境变量绑定的挂载。
157
+
148
158
  ### `onlyne_send{to, text, kind?, image?}`
149
159
 
150
160
  经 `send` 帧提交一个 envelope。`kind: "note"`(默认)是自由文本,不带 `op_id`。
@@ -160,7 +170,10 @@ png/jpeg/gif/webp 的绝对路径:插件读出内容,base64 编码后挂成
160
170
  `files` 是它点名的绝对路径:两者原样搭在 `report.complete` 帧上,也正是下游与发起方收到的东西,
161
171
  上限由 client 把守,超限正文由 client 用自己的句子拒绝(契约 §3c)。这一调用同时结束所在
162
172
  session 的进程。client 应答完 completion 报告(见 §4)之后,插件通过 `ctx.shutdown()` 让 pi
163
- 退出。pi 0.85.1 没有 tool-result `terminate` 处理。结果是 `reported <outcome>`。
173
+ 退出。pi 0.85.1 没有 tool-result `terminate` 处理。结果是 `reported <outcome>`;若 client 此前
174
+ 已经结算过该任务,它会用自己的记录作答,此时结果变成 `task already settled as <state> at
175
+ <settled_at>: <head> — this call changed nothing`——这是调用并未造成它所请求的那个结果的唯一
176
+ 情形。
164
177
 
165
178
  ### `onlyne_handoff{to, text, image?}`
166
179
 
@@ -175,32 +188,40 @@ hop 预算留在 envelope 的 `causality` 里,属于协议数据:投递文
175
188
 
176
189
  ## 4. outcome 判定规则
177
190
 
178
- `onlyne_complete` 是通向 `done` 的唯一路径。插件每个任务只发一次 completion,取以下三者的先到者:
191
+ `onlyne_complete` 是通向 `done` 的唯一路径,也是插件唯一的完成路径。插件每个任务只发一次
192
+ completion,取以下两者的先到者:
179
193
 
180
194
  1. **`onlyne_complete`** —— 模型给显式 outcome:`done`、`failed`、`cancelled` 或 `blocked`。同一
181
195
  任务的第二次 completion 被拒(不重报)。`summary` 非空时即 head,原样写出。
182
- 2. **turn 出错** —— turn 以 provider 错误告终(`stopReason: "error"`)。这本身就是证据,插件立即
183
- 报 `failed`,错误信息当 head。上报本身要等到 pi 正在等待输入时才发出,所以承载它的那次心跳
184
- 陈述的是 session 真实的阶段。
185
- 3. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
196
+ 2. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
186
197
  插件并退出 pi。
187
198
 
188
- 干净结束、却没有 completion 的 turn 在这里不结算任何东西。那条规则归 client
199
+ 没有 completion 就结束的 turn 在这里不结算任何东西,以错误结束的 turn 同样不结算。结束这件事归 client
189
200
  (`docs/v2-CONTRACT.md` 的 “3c. One turn-end rule”):它数这类结束、把自己的一句话作为 `nudge`
190
- 发来,并决定一次始终不上报的投递会变成什么。插件把那句话交给 pi,并且只回答自己交出去了,
191
- 于是措辞、计数与结算都只有一个主人,而不是两个。注入的消息还没跑过任何 turn 的任务,无论结算
192
- 信号说什么都不动:现在就报终态,等于声称干过一件没发生过的活。
201
+ 发来,并决定一次始终不上报的投递会变成什么;agent 已死的 session 由它自己的清扫负责。插件把那
202
+ 句话交给 pi,并且只回答自己交出去了,同时只上报自己的阶段与模型主动要求的 completion——措辞、
203
+ 计数与结算都只有一个主人,而不是两个。注入的消息还没跑过任何 turn 的任务,无论结算信号说什么都
204
+ 不动:现在就报终态,等于声称干过一件没发生过的活。
205
+
206
+ 以 provider 错误(`stopReason: "error"`)结束的 turn 也不是结论:pi 会对可重试的 provider 错误
207
+ 重试,并留在同一次 run 里,而 client 的 nudge 可能在 session 结算之后的一瞬重新启动它,所以在
208
+ 这里发报告只会与仍在进行的工作赛跑。错误只提供下面说的 head 退路。
193
209
 
194
210
  `head` 恒为单行、上限 200 字符,与 client 写入 `out_head` 和回执携带的内容一致。每个任务的 head
195
- 只有一个来源:显式 `onlyne_complete` 带 `summary` 时就是它,否则是出错 turn 报的错误。最后一段
196
- assistant 文本只是 `summary` 完全缺失的 `onlyne_complete` 的退路——调用之后再说的话顶不掉调用
197
- 交出的内容,此外没有任何东西读它。
211
+ 只有一个来源:显式 `onlyne_complete` 带 `summary` 时就是它。完全没有带 `summary` 的调用退到
212
+ session 最后说过的话——最后一段 assistant 文本,或失败 turn 留下的错误。错误永远只是这个退路:
213
+ 它本身从不构成 completion,此外没有任何东西读它;而调用之后再说的话顶不掉调用交出的内容。
198
214
 
199
215
  报出去的 completion 会结束所在 session 的进程。`report.complete` 以请求形式发出,client 只有
200
216
  在结算 session 行、ack 掉投递、并写好 `Completion` envelope 之后才应答,插件就在这个应答处
201
217
  让 pi 退出。socket 当时送不出去的 outcome 会被记住,并在下一次 `hello` 后补发,那次补发的
202
218
  应答就是结束进程的交接点。被宿主拒掉的 completion 不会让进程退出,任务不会因为退出而丢失。
203
219
 
220
+ 若 client 此前已经结算过该任务,它会对同一个请求回答 `{"already_settled": {state, settled_at,
221
+ head}}` 而不是 `null`。插件把这个记录原样转交,而不是声称一个并非由本次调用造成的结果,工具结果
222
+ 也照此说明(见 §3);在重连补发那条路径上没有工具结果可以承载它,记录会进入 `[pi-onlyne]` 日志。
223
+ 无论哪种情形任务都保持已结算:以 client 的那一行为准。
224
+
204
225
  completion 是插件最后一份上报;已经交掉任务的 session 不再发心跳,也不再应答 `probe`。终态的
205
226
  agent 维度由 client 自己写:随后到达的 `detach` 会退役这个已无任务的 session,该路径会喂入
206
227
  `AgentGone` 并发布这一行(`retire_idle_locked`,
@@ -218,8 +239,9 @@ steer 或 follow-up 消息、重试、压缩,以及被后台任务扩展移出
218
239
 
219
240
  后台任务扩展改变了这个问题。`bg_run` 与同类工具立即返回,工作继续在子进程里跑,于是 pi 在任务
220
241
  仍在进行时就等待输入。插件通过该扩展注册的工具认出它,再向它的 EventBus 服务查存活任务列表;
221
- 处于 `running` 的任务会把 session 按在 `running` 上,插件在轮次结束时欠下的那份报告——它自己
222
- 目睹的失败——也要等它结束。没有装这个扩展的 session 没有工具可认、没有查询,也没有东西要等。
242
+ 处于 `running` 的任务会把 session 按在 `running` 上,于是心跳陈述的是 session 真实的阶段。插件
243
+ 自己没有任何报告要等它结束:轮次结束时已经不再上报任何东西。没有装这个扩展的 session 没有工具
244
+ 可认、没有查询,也没有东西要等。
223
245
 
224
246
  ## 5. 协议说明与偏差
225
247
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "2.1.0",
3
+ "version": "2.2.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",
package/src/agent.mjs CHANGED
@@ -10,6 +10,16 @@
10
10
  // surface, which is why the whole state machine is testable against a plain
11
11
  // Node unix socket.
12
12
  //
13
+ // One completion path, and it is the model's: an explicit `onlyne_complete`
14
+ // (`completeFromTool`) or the host's `recycle{outcome}`. A turn that ends
15
+ // without one — cleanly or with a provider error — decides nothing here: the
16
+ // client's own turn-end rule owns the ending (`docs/v2-CONTRACT.md` §3c: one
17
+ // nudge, then the delivery settles `blocked`) and its own sweeps own a session
18
+ // whose agent died. A failed turn only supplies the head fallback, because pi
19
+ // retries a retryable provider error inside the same run and the client's nudge
20
+ // can restart a session a moment after it settles — a report sent on the error
21
+ // would race work that is still going.
22
+ //
13
23
  // What the human sees travels through one funnel, `notice` below: the pi widget
14
24
  // panel when the host has `ctx.ui.setWidget`, the footer status line plus a
15
25
  // `[pi-onlyne]` stderr line when it has not. `log` keeps the diagnostics — a
@@ -46,11 +56,6 @@ export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
46
56
  export const HELLO_TIMEOUT_MS = 5_000;
47
57
  /** Default bound on one request round trip. */
48
58
  export const REQUEST_TIMEOUT_MS = 30_000;
49
- /**
50
- * How long after an errored turn the plugin waits for `agent_settled` before it
51
- * reports the failure it witnessed itself.
52
- */
53
- export const SETTLE_FALLBACK_MS = 2_000;
54
59
 
55
60
  /** Capabilities this plugin implements on the wire. */
56
61
  export const CAPABILITIES = ["register", "report", "inject", "recycle"];
@@ -71,7 +76,6 @@ export class OnlyneAgent {
71
76
  * ladder?: number[],
72
77
  * requestTimeoutMs?: number,
73
78
  * helloTimeoutMs?: number,
74
- * settleFallbackMs?: number,
75
79
  * createConnection?: (path: string) => any,
76
80
  * timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
77
81
  * }} options
@@ -92,7 +96,6 @@ export class OnlyneAgent {
92
96
  this.ladder = options.ladder ?? RECONNECT_LADDER_MS;
93
97
  this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
94
98
  this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
95
- this.settleFallbackMs = options.settleFallbackMs ?? SETTLE_FALLBACK_MS;
96
99
  this.createConnection = options.createConnection ?? ((path) => createConnection(path));
97
100
  // The pane this process was spawned in, reported on every heartbeat so the
98
101
  // supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
@@ -122,7 +125,6 @@ export class OnlyneAgent {
122
125
  this.attempt = 0;
123
126
  this.reconnectHandle = null;
124
127
  this.heartbeatHandle = null;
125
- this.settleHandle = null;
126
128
  /**
127
129
  * The heartbeat round in flight, and what the next pass of it should say.
128
130
  * `heartbeat` never lets two rounds overlap, so `beatRound` doubles as the
@@ -710,10 +712,6 @@ export class OnlyneAgent {
710
712
  this.timer.clear(this.reconnectHandle);
711
713
  this.reconnectHandle = null;
712
714
  }
713
- if (this.settleHandle) {
714
- this.timer.clear(this.settleHandle);
715
- this.settleHandle = null;
716
- }
717
715
  this.rejectAll("agent stopped");
718
716
  }
719
717
 
@@ -775,9 +773,7 @@ export class OnlyneAgent {
775
773
  if (held && !held.completed) {
776
774
  // A new envelope for a task this session already holds — a follow-up, a
777
775
  // redirect, a bounce back through a handoff. The work record stays where
778
- // it is, and the failure an errored turn proved belonged to the
779
- // instruction being replaced, so it is cleared with that instruction.
780
- held.errored = false;
776
+ // it is; the newest instruction re-points it at the newest envelope.
781
777
  held.envelopeId = envelope.id ?? held.envelopeId;
782
778
  // `lastSeq` deliberately does not reset here. It is the floor the next
783
779
  // allocation for this task must clear, and the host's row for the task
@@ -800,7 +796,6 @@ export class OnlyneAgent {
800
796
  * share the plugin's counter. Null until the first report.
801
797
  */
802
798
  lastSeq: null,
803
- errored: false,
804
799
  head: "",
805
800
  });
806
801
  }
@@ -906,8 +901,10 @@ export class OnlyneAgent {
906
901
  *
907
902
  * How a turn end settles the work is the client's rule
908
903
  * (`docs/v2-CONTRACT.md` §3c): the plugin counts nothing, bounds nothing, and
909
- * reports only the facts it alone can see — its own phase, and an errored
910
- * turn.
904
+ * reports only its own phase and the completion the model asked for. A turn
905
+ * that ended without a completion is the client's case — its own one-nudge
906
+ * rule, and its own sweeps for a session whose agent died — so nothing here
907
+ * decides an outcome.
911
908
  */
912
909
  onTurnStart() {
913
910
  void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
@@ -925,40 +922,29 @@ export class OnlyneAgent {
925
922
  }
926
923
 
927
924
  /**
928
- * pi has settled: the session is waiting for input, which is the moment an
929
- * errored turn is reported from.
925
+ * pi has settled: the session is waiting for input. The phase is the whole of
926
+ * what this hook says — a settle is not a verdict, because the client's own
927
+ * nudge can restart a session a moment later and pi can still be inside a
928
+ * retry that settles it again.
930
929
  */
931
930
  onSettled() {
932
- this.clearSettleFallback();
933
931
  void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
934
- void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
935
932
  }
936
933
 
937
934
  /**
938
- * Report a turn that failed, once pi is quiet. pi keeps `isIdle()` false while
939
- * it is running, retrying, compacting, or holding a queued continuation, and
940
- * a background task keeps work running after pi itself has settled, so a
941
- * signal that arrives during any of those waits re-arms instead of reporting
942
- * from a session that is still busy. The fallback timer and `agent_settled`
943
- * both land here, and a task is reported once because the report completes it.
935
+ * A turn ended with an error (`stopReason: "error"`). The plugin sends
936
+ * nothing: an error is not proof of a terminal outcome, because pi retries a
937
+ * retryable provider error and stays inside the same run, and the client's
938
+ * nudge can restart a session a moment after it settles — so a report sent
939
+ * here would race work that is still going. What the error does supply is the
940
+ * head fallback for a later `onlyne_complete` whose `summary` carries nothing.
944
941
  */
945
- async trySettle() {
946
- if (this.closed || this.activeTasks().length === 0) return;
947
- if (await this.derivedPhase() !== "idle") {
948
- this.armSettleFallback();
949
- return;
950
- }
951
- await this.reportFailedTurns().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
952
- }
953
-
954
- /** A failed turn: the task's outcome is `failed`, with the error as its head. */
955
942
  onTurnError(text) {
956
943
  for (const task of this.tasks.values()) {
957
944
  if (task.completed) continue;
958
- task.errored = true;
959
945
  if (text) task.head = headOf(text);
960
946
  }
961
- void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
947
+ this.notice("warn", `turn ended with an error: ${headOf(text) || "no reason given"}`);
962
948
  }
963
949
 
964
950
  /**
@@ -979,38 +965,6 @@ export class OnlyneAgent {
979
965
  }
980
966
  }
981
967
 
982
- armSettleFallback() {
983
- this.clearSettleFallback();
984
- if (this.closed || this.activeTasks().length === 0) return;
985
- this.settleHandle = this.timer.set(() => {
986
- this.settleHandle = null;
987
- void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
988
- }, this.settleFallbackMs);
989
- }
990
-
991
- clearSettleFallback() {
992
- if (!this.settleHandle) return;
993
- this.timer.clear(this.settleHandle);
994
- this.settleHandle = null;
995
- }
996
-
997
- /**
998
- * Report the failures this session witnessed itself, for every task it still
999
- * holds.
1000
- *
1001
- * An errored turn is proof on its own — pi may skip the clean turn end — so
1002
- * its task is reported `failed` at once, with the error as its head. Nothing
1003
- * else is decided here: a turn that ends without a completion is the client's
1004
- * case, and it reaches the session as the host's nudge (`onNudge`), so a
1005
- * plugin that also decided it would be the second owner of the rule.
1006
- */
1007
- async reportFailedTurns() {
1008
- for (const task of [...this.tasks.values()]) {
1009
- if (task.completed || !task.errored) continue;
1010
- await this.complete(task.taskId, "failed", task.head);
1011
- }
1012
- }
1013
-
1014
968
  /**
1015
969
  * `onlyne_complete`: the model's explicit outcome, and the only path to
1016
970
  * `done`.
@@ -1100,8 +1054,9 @@ export class OnlyneAgent {
1100
1054
  // flight. A refused or broken request handed nothing over, so the task
1101
1055
  // goes back to open and the caller sees why: a retry reports again, and
1102
1056
  // `completeFromTool` still finds the task.
1057
+ let answer = null;
1103
1058
  try {
1104
- await this.request("report", report);
1059
+ answer = await this.request("report", report);
1105
1060
  } catch (error) {
1106
1061
  if (task) task.completed = false;
1107
1062
  throw error;
@@ -1114,6 +1069,14 @@ export class OnlyneAgent {
1114
1069
  this.stopHeartbeat();
1115
1070
  if (exitProcess) this.exitSession(normalized);
1116
1071
  }
1072
+ // A client that had already settled this task says so with its own record
1073
+ // of the terminal fact, and the caller hands that on rather than claiming
1074
+ // the completion it just sent. The task is still left settled here: the
1075
+ // client's row is the authority, and re-opening it would only ask the
1076
+ // model to report an outcome nobody is waiting for.
1077
+ if (isAlreadySettled(answer)) {
1078
+ return { taskId, outcome: normalized, head: summary, alreadySettled: answer.already_settled };
1079
+ }
1117
1080
  return { taskId, outcome: normalized, head: summary };
1118
1081
  }
1119
1082
 
@@ -1143,7 +1106,15 @@ export class OnlyneAgent {
1143
1106
  if (!this.connected) return;
1144
1107
  const pending = this.pendingCompletions.shift();
1145
1108
  try {
1146
- await this.request("report", pending.report);
1109
+ const answer = await this.request("report", pending.report);
1110
+ // A queued completion has no tool result to carry the client's record
1111
+ // back through, so the one thing worth saying is that the client had
1112
+ // already settled the task and this report changed nothing.
1113
+ if (isAlreadySettled(answer)) {
1114
+ this.log(
1115
+ `queued completion for ${pending.taskId.slice(0, 8)} was already settled by the client as ${answer.already_settled.state}`
1116
+ );
1117
+ }
1147
1118
  this.stats.completions += 1;
1148
1119
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
1149
1120
  this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
@@ -1233,6 +1204,17 @@ export class OnlyneAgent {
1233
1204
  }
1234
1205
  }
1235
1206
 
1207
+ /**
1208
+ * Whether a client's answer to a `report.complete` says the task had already
1209
+ * settled. The shape is the client's (`{"already_settled": {state, settled_at,
1210
+ * head}}`); a normal accepted completion answers `data = null`, which is not an
1211
+ * object and reads as no.
1212
+ */
1213
+ function isAlreadySettled(answer) {
1214
+ return Boolean(answer) && typeof answer === "object" && answer.already_settled !== null
1215
+ && typeof answer.already_settled === "object";
1216
+ }
1217
+
1236
1218
  /** Mime type for one attachment path the model asked to send. */
1237
1219
  export function mimeForPath(path) {
1238
1220
  const lower = String(path).toLowerCase();
@@ -117,6 +117,9 @@ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
117
117
  * reports before answering the rest, the way a client whose settle failed would.
118
118
  * `request` sends a host frame carrying an id, which is how the plugin is asked
119
119
  * for an answer (`nudge`); `answers` reads what came back.
120
+ * `alreadySettled` answers every `complete` report with the client's
121
+ * already-settled shape in `data`, the way a client that had settled the task
122
+ * before the report arrived does.
120
123
  */
121
124
  class FakeHost {
122
125
  constructor({
@@ -125,6 +128,7 @@ class FakeHost {
125
128
  failSend = false,
126
129
  handoffError = null,
127
130
  failCompletions = 0,
131
+ alreadySettled = null,
128
132
  } = {}) {
129
133
  this.server = createServer((socket) => this.onConnection(socket));
130
134
  this.frames = [];
@@ -135,6 +139,7 @@ class FakeHost {
135
139
  this.failSend = failSend;
136
140
  this.handoffError = handoffError;
137
141
  this.failCompletions = failCompletions;
142
+ this.alreadySettled = alreadySettled;
138
143
  this.held = [];
139
144
  }
140
145
 
@@ -189,6 +194,9 @@ class FakeHost {
189
194
  }));
190
195
  return;
191
196
  }
197
+ if (this.alreadySettled && frame.op === "report" && frame.args?.kind === "complete") {
198
+ body = { ok: true, data: { already_settled: this.alreadySettled } };
199
+ }
192
200
  if (this.failSend && frame.op === "send") {
193
201
  socket.write(encodeFrame({
194
202
  reply_to: frame.id,
@@ -293,10 +301,11 @@ async function startAgent({
293
301
  failSend = false,
294
302
  handoffError = null,
295
303
  failCompletions = 0,
304
+ alreadySettled = null,
296
305
  } = {}) {
297
306
  const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
298
307
  const socketPath = join(dir, "s");
299
- const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError, failCompletions });
308
+ const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError, failCompletions, alreadySettled });
300
309
  await host.listen(socketPath);
301
310
  const logs = [];
302
311
  const agent = new OnlyneAgent({
@@ -308,9 +317,6 @@ async function startAgent({
308
317
  surface,
309
318
  log: (line) => logs.push(line),
310
319
  ladder: [20, 40, 80],
311
- // The fallback is the agent_settled-less path; a test that wants it asks for
312
- // a short window, and every other test drives the exit explicitly.
313
- settleFallbackMs: options.settleFallbackMs ?? 30_000,
314
320
  heartbeatMs: options.heartbeatMs ?? 60_000,
315
321
  ...(capabilities ? { capabilities } : {}),
316
322
  ...(options.host !== undefined ? { host: options.host } : {}),
@@ -688,30 +694,37 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
688
694
  assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
689
695
  });
690
696
 
691
- // The plugin still owes one report at a turn end: the failure it witnessed
692
- // itself. That report waits for the idle it is about, exactly as the phase-based
693
- // signal always did.
694
- test("a busy pi holds the failed-turn report until it is waiting for input", async () => {
695
- let waiting = false;
696
- const surface = fakeSurface({ waiting: () => waiting });
697
- const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
697
+ // A turn that ends with an error is not a verdict. pi retries a retryable
698
+ // provider error and stays inside the same run, and the client's nudge can
699
+ // restart a session a moment after it settles, so a report sent from here would
700
+ // race work that is still going. The client's own turn-end rule (§3c) owns the
701
+ // ending, and its sweeps own a session whose agent died; the plugin states its
702
+ // phase and nothing else.
703
+ test("a turn error sends no completion, even after a settle and a wait", async () => {
704
+ const { agent, host, surface } = await startAgent();
698
705
  agent.start();
699
706
  await waitFor(() => host.of("report").length >= 1);
700
707
  host.notify("assign", assignArgs());
701
708
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
709
+
710
+ agent.onTurnStart();
702
711
  agent.onTurnError("provider exploded");
703
- await new Promise((resolve) => setTimeout(resolve, 80));
704
- assert.deepEqual(completions(host), [], "a busy pi is not reported on yet");
712
+ await waitFor(() => heartbeats(host).some((observed) => observed.agent === "running"));
713
+ agent.onSettled();
714
+ // Past the window the deleted fallback used to fire in (40 ms in the harness
715
+ // that pinned it), so a plugin that still armed one would have reported here.
716
+ await new Promise((resolve) => setTimeout(resolve, 150));
705
717
 
706
- waiting = true;
707
- const complete = await waitFor(() => completions(host)[0] ?? null);
708
- assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
718
+ assert.deepEqual(completions(host), [], "a failed turn reports no outcome nobody claimed");
719
+ assert.deepEqual(agent.status().tasks, [TASK_ID], "the task is still open");
720
+ assert.deepEqual(surface.calls.exits, [], "and the session stays mounted");
709
721
  });
710
722
 
711
- test("a live background task holds the failed-turn report until its task is terminal", async () => {
712
- let backgroundWork = true;
713
- const surface = fakeSurface({ backgroundWork: () => backgroundWork });
714
- const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
723
+ // What the error does supply is the head fallback: a completion whose summary
724
+ // carries nothing still has a display line, and it is the sentence the failed
725
+ // turn left behind.
726
+ test("an explicit completion after a turn error uses the error as its head", async () => {
727
+ const { agent, host, surface } = await startAgent();
715
728
  agent.start();
716
729
  await waitFor(() => host.of("report").length >= 1);
717
730
  host.notify("assign", assignArgs());
@@ -719,14 +732,11 @@ test("a live background task holds the failed-turn report until its task is term
719
732
 
720
733
  agent.onTurnStart();
721
734
  agent.onTurnError("provider exploded");
722
- await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
723
- await new Promise((resolve) => setTimeout(resolve, 80));
724
- assert.deepEqual(completions(host), [], "work off the agent loop keeps the report off");
735
+ const result = await agent.completeFromTool({ outcome: "done" });
725
736
 
726
- backgroundWork = false;
727
- agent.onSettled();
728
- const complete = await waitFor(() => completions(host)[0] ?? null);
729
- assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
737
+ assert.equal(result.head, "provider exploded", "the error text is the head fallback");
738
+ const [complete] = completions(host);
739
+ assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "done", head: "provider exploded" });
730
740
  });
731
741
 
732
742
  // The turn-end rule has one owner, the client (`docs/v2-CONTRACT.md` §3c): a
@@ -735,15 +745,15 @@ test("a live background task holds the failed-turn report until its task is term
735
745
  // plugin's answer to a clean turn end is its beat and nothing else — no
736
746
  // injection, no count, and no outcome nobody claimed.
737
747
  test("a turn that ends without a completion settles nothing and says nothing", async () => {
738
- const { agent, host, surface } = await startAgent({ options: { settleFallbackMs: 40 } });
748
+ const { agent, host, surface } = await startAgent();
739
749
  agent.start();
740
750
  await waitFor(() => host.of("report").length >= 1);
741
751
  host.notify("assign", assignArgs());
742
752
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
743
753
  agent.noteAssistantText("OK");
744
754
 
745
- // Two clean turns and a settle after each: the fallback window and the
746
- // settled signal both land, and neither has anything to decide.
755
+ // Two clean turns and a settle after each: the settled signal lands, and it
756
+ // has nothing to decide.
747
757
  agent.onTurnStart();
748
758
  agent.onTurnEnd();
749
759
  agent.onSettled();
@@ -772,18 +782,6 @@ test("an assigned task that never ran is not completed", async () => {
772
782
  assert.deepEqual(agent.status().tasks, [TASK_ID]);
773
783
  });
774
784
 
775
- test("a failed turn settles the task as failed with the error as head", async () => {
776
- const { agent, host, surface } = await startAgent();
777
- agent.start();
778
- await waitFor(() => host.of("report").length >= 1);
779
- host.notify("assign", assignArgs());
780
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
781
- agent.onTurnStart();
782
- agent.onTurnError("provider exploded");
783
- const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
784
- assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
785
- });
786
-
787
785
  test("an explicit tool outcome wins and a second completion is refused", async () => {
788
786
  const { agent, host, surface } = await startAgent();
789
787
  agent.start();
@@ -1277,7 +1275,6 @@ test("a host that never answers the hello is dropped and retried", async () => {
1277
1275
  log: (line) => logs.push(line),
1278
1276
  ladder: [20],
1279
1277
  helloTimeoutMs: 40,
1280
- settleFallbackMs: 30,
1281
1278
  });
1282
1279
  cleanups.push(async () => {
1283
1280
  agent.stop("test");
@@ -1314,6 +1311,40 @@ test("a completion sends the summary as the head and adds nothing else", async (
1314
1311
  assert.equal(JSON.stringify(completions(host)[0]), COMPLETE_VECTOR);
1315
1312
  });
1316
1313
 
1314
+ // A client that had already settled this task answers the report with its own
1315
+ // record instead of `data: null`. The completion still leaves the task settled —
1316
+ // the client's row is the authority — but the caller hands the record on, so the
1317
+ // tool result can say the call changed nothing rather than claim the outcome.
1318
+ test("a report the client answers as already settled comes back as the client's record", async () => {
1319
+ const settled = {
1320
+ state: "failed",
1321
+ settled_at: "2026-10-11T05:15:47Z",
1322
+ head: "provider exploded",
1323
+ };
1324
+ const { agent, host } = await startAgent({ alreadySettled: settled });
1325
+ agent.start();
1326
+ await waitFor(() => host.of("report").length >= 1);
1327
+
1328
+ const result = await agent.completeFromTool({ outcome: "done", summary: "finished it" });
1329
+ assert.deepEqual(result, {
1330
+ taskId: TASK_ID,
1331
+ outcome: "done",
1332
+ head: "finished it",
1333
+ alreadySettled: settled,
1334
+ });
1335
+ assert.equal(completions(host).length, 1, "the report still went out");
1336
+ assert.deepEqual(agent.status().tasks, [], "the client's row is the authority");
1337
+ });
1338
+
1339
+ test("a normal accepted completion carries no already-settled record", async () => {
1340
+ const { agent, host } = await startAgent();
1341
+ agent.start();
1342
+ await waitFor(() => host.of("report").length >= 1);
1343
+
1344
+ const result = await agent.completeFromTool({ outcome: "done", summary: "finished it" });
1345
+ assert.deepEqual(result, { taskId: TASK_ID, outcome: "done", head: "finished it" });
1346
+ });
1347
+
1317
1348
  test("details and files ride the completion unchanged", async () => {
1318
1349
  const { agent, host } = await startAgent();
1319
1350
  agent.start();
@@ -1374,7 +1405,7 @@ test("a nudge hands the client's sentence over as it stands and answers ok", asy
1374
1405
  // A nudge is not a delivery and not a report: the task stays open, its record
1375
1406
  // is untouched, and no frame of the plugin's own follows.
1376
1407
  assert.equal(agent.tasks.get(TASK_ID), record);
1377
- assert.equal(record.errored, false);
1408
+ assert.equal(record.head, "", "the nudge leaves the head fallback alone");
1378
1409
  assert.deepEqual(host.of("report").slice(reportsBefore), []);
1379
1410
  });
1380
1411
 
package/src/index.ts CHANGED
@@ -7,20 +7,34 @@
7
7
  // `crates/onlyne-client/src/dispatch.rs`); with any of the three missing this is
8
8
  // a plain pi session and the extension stays silent rather than failing.
9
9
  //
10
- // session_start -> read env + .pi/onlyne.json, connect, register tools
10
+ // session_start -> bind the env session id, read env + .pi/onlyne.json,
11
+ // connect, register tools
11
12
  // before_agent_start -> the role prose becomes one section of the system
12
13
  // prompt the run is about to send (the instruction layer)
13
14
  // turn_start -> heartbeat{running}
14
15
  // turn_end -> one turn of a run ended; the phase is re-derived from pi
15
- // and the fallback window for a witnessed failure opens
16
- // message_end -> keep the last assistant text; a failed turn is `failed`
17
- // agent_settled -> heartbeat{idle} when the session waits for input, and the
18
- // report a failed turn owes is sent from here
16
+ // message_end -> keep the last assistant text as the head fallback
17
+ // agent_settled -> heartbeat{idle} when the session waits for input
19
18
  // session_shutdown -> detach{reason}
20
19
  //
20
+ // The plugin keeps exactly one completion path: the model's explicit
21
+ // `onlyne_complete` (and `recycle{outcome}`). A turn that ends without one
22
+ // decides nothing here — the client's own turn-end rule owns it
23
+ // (`docs/v2-CONTRACT.md` §3c: one nudge, then the delivery settles `blocked`)
24
+ // and its sweeps own a session whose agent died. A turn error only supplies the
25
+ // head fallback, because pi retries a retryable provider error and stays inside
26
+ // the same run, and the client's nudge can restart a session a moment after it
27
+ // settles — a report sent on a turn error would race work that is still going.
28
+ //
21
29
  // Host frames are dispatched in `agent.mjs`, not here: `assign` and `nudge` are
22
30
  // injected as user messages, `probe` is answered with a heartbeat, and `recycle`
23
31
  // settles the task and stops the plugin.
32
+ //
33
+ // Only the session whose id equals `ONLYNE_SESSION_ID` mounts: a pi process runs
34
+ // a subagent sidechain and `/new` sessions too, all inheriting this extension's
35
+ // environment, and a sidechain that called `onlyne_complete` would settle the
36
+ // parent's task. `session-scope.mjs` carries the rule, the guard behind it, and
37
+ // what a call from a foreign session is told.
24
38
 
25
39
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
26
40
  import { Type } from "typebox";
@@ -29,6 +43,7 @@ import { OnlyneAgent } from "./agent.mjs";
29
43
  import { loadConfig, sessionIdentity } from "./config.mjs";
30
44
  import { resolveSocketPath } from "./socket.mjs";
31
45
  import { createSurface } from "./pi-surface.mjs";
46
+ import { foreignSessionRefusal, mountsSession, ownsSession, sessionIdOf } from "./session-scope.mjs";
32
47
 
33
48
  /**
34
49
  * Node's process globals, declared here so the package needs no `@types/node`
@@ -131,12 +146,29 @@ export default function onlyne(pi: ExtensionAPI) {
131
146
  let agent: OnlyneAgent | null = null;
132
147
  let surface: PiSurface | null = null;
133
148
  let registered = false;
149
+ /**
150
+ * The session id that mounted this plugin. A pi process can host more than
151
+ * one session — a `task` subagent runs a sidechain in the same process and
152
+ * inherits `ONLYNE_*`, and `/new` starts another — and every one of them
153
+ * loads this extension. The env names the one session that is the onlyne
154
+ * session, so the tools belong to it alone; an unbound session must not be
155
+ * able to settle this task by calling `onlyne_complete` from its sidechain.
156
+ */
157
+ let boundSessionId: string | null = null;
134
158
 
135
159
  const log = (line: string, data?: unknown) => {
136
160
  const detail = data === undefined ? "" : ` ${JSON.stringify(data)}`;
137
161
  process?.stderr?.write(`[pi-onlyne] ${line}${detail}\n`);
138
162
  };
139
163
 
164
+ /**
165
+ * Guard one tool call: the caller must be the session that mounted this
166
+ * plugin (`session-scope.mjs` carries the rule and the reason).
167
+ */
168
+ const refuseForeignSession = (caller: string | null): never => {
169
+ throw new Error(foreignSessionRefusal(boundSessionId, caller));
170
+ };
171
+
140
172
  /** Tools and the `/onlyne` command exist only inside an onlyne session. */
141
173
  const registerSurface = () => {
142
174
  if (registered || !surface) return;
@@ -157,7 +189,9 @@ export default function onlyne(pi: ExtensionAPI) {
157
189
  kind: Type.Optional(Type.String({ description: '"note" (default) or "task"' })),
158
190
  image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
159
191
  }),
160
- async execute(_toolCallId, params) {
192
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
193
+ const caller = sessionIdOf(ctx?.sessionManager);
194
+ if (!ownsSession(boundSessionId, caller)) refuseForeignSession(caller);
161
195
  if (!agent) throw new Error("onlyne: session is not connected");
162
196
  const result = await agent.sendFromTool({
163
197
  to: params.to,
@@ -189,7 +223,9 @@ export default function onlyne(pi: ExtensionAPI) {
189
223
  details: Type.Optional(Type.String({ description: "the full result, delivered as it stands" })),
190
224
  files: Type.Optional(Type.Array(Type.String(), { description: "absolute paths of the files the result names" })),
191
225
  }),
192
- async execute(_toolCallId, params) {
226
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
227
+ const caller = sessionIdOf(ctx?.sessionManager);
228
+ if (!ownsSession(boundSessionId, caller)) refuseForeignSession(caller);
193
229
  if (!agent) throw new Error("onlyne: session is not connected");
194
230
  // The exit is not a tool-result flag: pi 0.85.1 has no tool-result
195
231
  // `terminate` handling. `agent.complete` asks the surface to shut the
@@ -202,6 +238,17 @@ export default function onlyne(pi: ExtensionAPI) {
202
238
  details: params.details,
203
239
  files: params.files,
204
240
  });
241
+ // The client that had already settled this task answers with its own
242
+ // record, and the sentence says so rather than claiming a completion
243
+ // this call did not cause.
244
+ if (result.alreadySettled) {
245
+ const settled = result.alreadySettled;
246
+ const head = settled.head ? `: ${settled.head}` : "";
247
+ return textResult(
248
+ `task already settled as ${settled.state} at ${settled.settled_at}${head} — this call changed nothing`,
249
+ { outcome: result.outcome, alreadySettled: settled }
250
+ );
251
+ }
205
252
  // The outcome and nothing else: the ledger's head stays a display
206
253
  // field (docs/v2-CONTRACT.md §3c), and the task's identity is not a
207
254
  // fact the model is meant to hold.
@@ -226,7 +273,9 @@ export default function onlyne(pi: ExtensionAPI) {
226
273
  text: Type.String({ description: "handoff text for the receiving role" }),
227
274
  image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
228
275
  }),
229
- async execute(_toolCallId, params) {
276
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
277
+ const caller = sessionIdOf(ctx?.sessionManager);
278
+ if (!ownsSession(boundSessionId, caller)) refuseForeignSession(caller);
230
279
  if (!agent) throw new Error("onlyne: session is not connected");
231
280
  const result = await agent.handoffFromTool({
232
281
  to: params.to,
@@ -246,6 +295,17 @@ export default function onlyne(pi: ExtensionAPI) {
246
295
  pi.registerCommand("onlyne", {
247
296
  description: "Onlyne session status: connection, task, reports",
248
297
  handler: async (argLine, commandContext) => {
298
+ // The command drives this plugin's connection, so it belongs to the
299
+ // bound session for the same reason the tools do: a sidechain that
300
+ // issued `/onlyne disconnect` would detach the real session.
301
+ const caller = sessionIdOf(commandContext?.sessionManager);
302
+ if (!ownsSession(boundSessionId, caller)) {
303
+ commandContext.ui.notify(
304
+ foreignSessionRefusal(boundSessionId, caller),
305
+ "info"
306
+ );
307
+ return;
308
+ }
249
309
  if (!agent) {
250
310
  commandContext.ui.notify("onlyne: no session (ONLYNE_* env is absent)", "info");
251
311
  return;
@@ -270,9 +330,22 @@ export default function onlyne(pi: ExtensionAPI) {
270
330
  };
271
331
 
272
332
  pi.on("session_start", async (_event, ctx) => {
273
- context = ctx;
274
333
  const identity = sessionIdentity(env);
275
334
  if (!identity) return; // not an onlyne session: stay out of the way
335
+ // Only the session the client names may mount. A pi process runs more than
336
+ // one session — a `task` subagent's sidechain, `/new` — and each of them
337
+ // loads this extension with the same inherited environment, so without this
338
+ // check a subagent's `onlyne_complete` settles the parent's task (observed:
339
+ // librarian task cfa73386). An unknown id means an older pi that does not
340
+ // expose one; the env-bound mount is then the only candidate and stays the
341
+ // behaviour, with no line to explain.
342
+ const starting = sessionIdOf(ctx.sessionManager);
343
+ if (!mountsSession(identity.sessionId, starting)) {
344
+ log(`session ${starting} is not the onlyne session ${identity.sessionId}; staying inert`);
345
+ return;
346
+ }
347
+ context = ctx;
348
+ boundSessionId = identity.sessionId;
276
349
  const config = loadConfig(ctx.cwd);
277
350
  if (config.warning) log(config.warning);
278
351
  if (!config.enabled) {
@@ -351,5 +424,6 @@ export default function onlyne(pi: ExtensionAPI) {
351
424
  surface = null;
352
425
  registered = false;
353
426
  context = null;
427
+ boundSessionId = null;
354
428
  });
355
429
  }
@@ -0,0 +1,90 @@
1
+ // One question, asked twice: is the session in front of us the one the client
2
+ // spawned this process for?
3
+ //
4
+ // A pi process runs more than one session. The client spawns pi with
5
+ // `--session-id <the delivery's session>` and injects `ONLYNE_*` into the
6
+ // process environment, and pi then runs other sessions inside that same process
7
+ // with the same environment: a `task` subagent's sidechain
8
+ // (`@tintinweb/pi-subagents` re-binds every extension for the child,
9
+ // `session.bindExtensions`), and `/new`. Each of those loads this extension and
10
+ // sees the parent's `ONLYNE_SESSION_ID`, so a rule that read the environment
11
+ // alone would mount a second connection for the parent's session and let a
12
+ // subagent's `onlyne_complete` settle the parent's task — which is exactly what
13
+ // happened live (librarian task cfa73386, 2026-08-11: a writer subagent settled
14
+ // its parent at 05:15:47 while the parent kept writing).
15
+ //
16
+ // So the rule is the session's own id, read off the pid pi gave the handler, and
17
+ // the environment is only the expectation it is measured against. The two
18
+ // readings are separate on purpose: `mountsSession` is the door (does this
19
+ // session get a connection and the tools at all), `ownsSession` is the guard
20
+ // behind it (may this caller's tool call move the task), and a pi that cannot
21
+ // report a session id at all is the one case where both answer yes — refusing
22
+ // every call on an older pi would break the ordinary session to prevent nothing,
23
+ // because the env-bound mount is still the only one that connected.
24
+
25
+ /**
26
+ * The id of the session a pi extension context belongs to.
27
+ *
28
+ * `null` means this pi cannot tell — no `sessionManager`, or one without
29
+ * `getSessionId` — which is the honest reading of an unknown, and both rules
30
+ * below treat it as "allowed" for the reason in the header.
31
+ *
32
+ * @param {{ getSessionId?: () => unknown } | null | undefined} sessionManager
33
+ * @returns {string | null}
34
+ */
35
+ export function sessionIdOf(sessionManager) {
36
+ try {
37
+ const id = sessionManager?.getSessionId?.();
38
+ return typeof id === "string" && id.length > 0 ? id : null;
39
+ } catch {
40
+ // A context that throws is a context this plugin cannot read; the caller
41
+ // gets the same answer as one that is absent.
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Whether the session pi is starting is the one the client spawned this process
48
+ * for, and therefore whether it may mount.
49
+ *
50
+ * @param {string} identitySessionId `ONLYNE_SESSION_ID`, the client's own word
51
+ * @param {string | null} startingSessionId the session's own id, or `null`
52
+ * @returns {boolean}
53
+ */
54
+ export function mountsSession(identitySessionId, startingSessionId) {
55
+ return startingSessionId === null || startingSessionId === identitySessionId;
56
+ }
57
+
58
+ /**
59
+ * Whether a call comes from the session that mounted this plugin.
60
+ *
61
+ * The guard behind the tools and the `/onlyne` command: a sidechain that reached
62
+ * a tool through its parent's registration must not settle the parent's task,
63
+ * and the sentence it earns says so rather than reporting an outcome nobody
64
+ * asked this session for.
65
+ *
66
+ * @param {string | null} boundSessionId the session that mounted, or `null`
67
+ * before one has
68
+ * @param {string | null} callingSessionId the session the call came from
69
+ * @returns {boolean}
70
+ */
71
+ export function ownsSession(boundSessionId, callingSessionId) {
72
+ if (callingSessionId === null) return true;
73
+ return boundSessionId !== null && callingSessionId === boundSessionId;
74
+ }
75
+
76
+ /**
77
+ * The one sentence a call from a foreign session earns, for a tool error and for
78
+ * a command notice alike.
79
+ *
80
+ * @param {string | null} boundSessionId
81
+ * @param {string | null} callingSessionId
82
+ * @returns {string}
83
+ */
84
+ export function foreignSessionRefusal(boundSessionId, callingSessionId) {
85
+ return (
86
+ `onlyne: session ${callingSessionId} did not mount these tools; they belong to onlyne ` +
87
+ `session ${boundSessionId}, and a subagent or another session in the same pi process ` +
88
+ `must not settle this task`
89
+ );
90
+ }
@@ -0,0 +1,61 @@
1
+ // The rule that keeps a pi subagent from settling its parent's task, and the
2
+ // sentence a foreign caller reads. The live defect these pin: a `task` subagent
3
+ // runs a sidechain in the same pi process with the same inherited environment,
4
+ // so it saw the parent's `ONLYNE_SESSION_ID` and its `onlyne_complete` settled
5
+ // the parent's task (librarian cfa73386, 2026-08-11).
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+ import {
9
+ foreignSessionRefusal,
10
+ mountsSession,
11
+ ownsSession,
12
+ sessionIdOf,
13
+ } from "./session-scope.mjs";
14
+
15
+ const PARENT = "a379b47d-65b7-49c3-ad4b-b978cdf5a5d3";
16
+ const SIDECHAIN = "4f1cb760-c254-4ea1-9d55-6f2a1c8bb0e1";
17
+
18
+ test("the session id is read off the manager, and an unreadable one reads as unknown", () => {
19
+ assert.equal(sessionIdOf({ getSessionId: () => PARENT }), PARENT);
20
+ assert.equal(sessionIdOf({}), null, "no accessor, no id");
21
+ assert.equal(sessionIdOf(undefined), null);
22
+ assert.equal(sessionIdOf({ getSessionId: () => "" }), null, "an empty id names nothing");
23
+ assert.equal(sessionIdOf({ getSessionId: () => 7 }), null, "a non-string id is not one");
24
+ assert.equal(
25
+ sessionIdOf({
26
+ getSessionId: () => {
27
+ throw new Error("stale context");
28
+ },
29
+ }),
30
+ null,
31
+ "a context that throws is one this plugin cannot read",
32
+ );
33
+ });
34
+
35
+ test("only the session the client named mounts, and an unknown id does not stop the mount", () => {
36
+ assert.equal(mountsSession(PARENT, PARENT), true, "the spawned session is the one");
37
+ assert.equal(mountsSession(PARENT, SIDECHAIN), false, "a subagent sidechain is not");
38
+ assert.equal(
39
+ mountsSession(PARENT, null),
40
+ true,
41
+ "an older pi that cannot say which session this is keeps the env-bound mount",
42
+ );
43
+ });
44
+
45
+ test("a call is the bound session's unless pi names another session", () => {
46
+ assert.equal(ownsSession(PARENT, PARENT), true);
47
+ assert.equal(ownsSession(PARENT, SIDECHAIN), false, "the sidechain must not settle the parent");
48
+ assert.equal(ownsSession(PARENT, null), true, "a caller this pi cannot identify is allowed");
49
+ assert.equal(
50
+ ownsSession(null, SIDECHAIN),
51
+ false,
52
+ "a runner with no bound session has no session whose calls are its own",
53
+ );
54
+ });
55
+
56
+ test("the refusal names both sessions and why the call is refused", () => {
57
+ const sentence = foreignSessionRefusal(PARENT, SIDECHAIN);
58
+ assert.ok(sentence.includes(SIDECHAIN), sentence);
59
+ assert.ok(sentence.includes(PARENT), sentence);
60
+ assert.ok(sentence.includes("subagent"), sentence);
61
+ });