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 +62 -29
- package/README.zh.md +37 -15
- package/package.json +1 -1
- package/src/agent.mjs +56 -74
- package/src/agent.test.mjs +75 -44
- package/src/index.ts +83 -9
- package/src/session-scope.mjs +90 -0
- package/src/session-scope.test.mjs +61 -0
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
|
|
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.
|
|
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
|
|
213
|
-
|
|
214
|
-
own sentence as a `nudge`, and decides what a
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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`,
|
|
260
|
-
|
|
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.
|
|
545
|
-
|
|
574
|
+
2. **`recycle{outcome}`**——主机正在拆除会话。插件先使用主机给出的结果确定尚未确定状态的任务,然后停止并退出 pi。
|
|
575
|
+
|
|
576
|
+
没有完成就结束的轮次在这里不结算任何东西,以错误结束的轮次同样不结算。结束这件事归 client(`docs/v2-CONTRACT.md` 的「3c. One turn-end rule」):它统计这类结束、把自己的一句话作为 `nudge` 发来,并决定一次始终不上报的投递会变成什么;agent 已死的会话由它自己的清扫负责。插件把那句话交给 pi,并且只回答自己交出去了,同时只上报自己的阶段与模型主动要求的完成——措辞、计数与结算都只有一个拥有者,而不是两个。注入消息尚未跑过任何轮次的任务,无论结算信号说什么都不动:现在就上报等于宣称从未发生的工作已经完成。
|
|
546
577
|
|
|
547
|
-
|
|
578
|
+
以提供方错误(`stopReason: "error"`)结束的轮次也不是结论:pi 会对可重试的提供方错误重试,并留在同一次运行里,而 client 的 nudge 可能在会话结算之后的一瞬间重新启动它,所以在这里发出报告只会与仍在进行的工作赛跑。错误只提供下面说的 head 回退。
|
|
548
579
|
|
|
549
|
-
`head` 是单行文本,上限为 200 个字符;它与客户端写入 `out_head` 的内容以及回执携带的内容一致。每个任务的 `head` 只有一个来源:显式 `onlyne_complete` 调用携带 `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`
|
|
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.
|
|
183
|
-
报 `failed`,错误信息当 head。上报本身要等到 pi 正在等待输入时才发出,所以承载它的那次心跳
|
|
184
|
-
陈述的是 session 真实的阶段。
|
|
185
|
-
3. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
|
|
196
|
+
2. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
|
|
186
197
|
插件并退出 pi。
|
|
187
198
|
|
|
188
|
-
|
|
199
|
+
没有 completion 就结束的 turn 在这里不结算任何东西,以错误结束的 turn 同样不结算。结束这件事归 client
|
|
189
200
|
(`docs/v2-CONTRACT.md` 的 “3c. One turn-end rule”):它数这类结束、把自己的一句话作为 `nudge`
|
|
190
|
-
|
|
191
|
-
|
|
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`
|
|
196
|
-
|
|
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
|
-
|
|
242
|
+
处于 `running` 的任务会把 session 按在 `running` 上,于是心跳陈述的是 session 真实的阶段。插件
|
|
243
|
+
自己没有任何报告要等它结束:轮次结束时已经不再上报任何东西。没有装这个扩展的 session 没有工具
|
|
244
|
+
可认、没有查询,也没有东西要等。
|
|
223
245
|
|
|
224
246
|
## 5. 协议说明与偏差
|
|
225
247
|
|
package/package.json
CHANGED
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
|
|
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
|
|
910
|
-
*
|
|
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
|
|
929
|
-
*
|
|
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
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
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
|
-
|
|
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();
|
package/src/agent.test.mjs
CHANGED
|
@@ -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
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
assert.deepEqual(
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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(
|
|
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
|
|
746
|
-
//
|
|
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.
|
|
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,
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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
|
+
});
|