pi-onlyne 1.2.0 → 1.2.2

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
@@ -24,12 +24,13 @@ hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{r
24
24
  ◀── assign{envelope, prose, task_id, generation}
25
25
  ├─ task text (+ image path) ──► pi user message (deliverAs:"followUp")
26
26
  ├─ assign_ack{accepted:true}
27
- ├─ report.heartbeat{running|idle} — per turn, and every 10s while a task is live
28
- ├─ a turn that ends without `onlyne_complete` ──► the idle ladder: the same
29
- │ message again (at most `idleReminders` times), then `failed` and out
30
- ├─ report.complete{outcome, head} — the ledger's terminal fact
31
- │ └─ then one report.heartbeat{agent:"idle"} stating the agent only
32
- │ └─ the client's answer is the handover: pi is asked to shut down, then detaches
27
+ ├─ report.heartbeat{agent} — `running` per turn and every 10s while a task is live,
28
+ │ `idle` only while pi waits for input; every beat re-derives it from pi
29
+ ├─ the session waits for input with the task open and no `onlyne_complete` ──►
30
+ │ the idle ladder: the same message again (at most `idleReminders` times),
31
+ │ then `failed` and out
32
+ ├─ report.complete{outcome, head} — the ledger's terminal fact, and the last report
33
+ │ └─ the client's answer is the handover: pi is asked to shut down, then detaches
33
34
  ├─ probe ──► one heartbeat
34
35
  ◀── recycle ──► complete (if unsettled) → stop → pi exits
35
36
  └─ detach{reason} when pi shuts down
@@ -104,7 +105,7 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
104
105
  | --- | --- | --- |
105
106
  | `enabled` | `true` | `false` turns the extension off for this workspace |
106
107
  | `watch.autoStart` | `true` | `false` registers the tools but opens no socket until `/onlyne connect` |
107
- | `idleReminders` | `2` | how many times an idle turn end re-sends the assignment before the task fails (§4); 0 means the first idle without a completion fails it |
108
+ | `idleReminders` | `2` | how many times one idle episode re-sends the assignment before the task fails (§4); 0 means the first idle without a completion fails it |
108
109
 
109
110
  A missing file means every default. A malformed file prints one warning on stderr and
110
111
  keeps the defaults: a typo must not silently disable a role. The client does not read
@@ -190,11 +191,11 @@ at the first of these events:
190
191
  2. **An errored turn** — the turn ended with a provider error (`stopReason: "error"`).
191
192
  That is proof on its own, so the plugin reports `failed` at once, with the error as
192
193
  the head.
193
- 3. **The idle ladder** — the turn ended cleanly without a completion, and the task is
194
- still open. The plugin re-sends the assignment and counts the rung. The idle that
195
- finds the bound `idleReminders` names already spent reports `failed` — head
196
- `no completion after <n> idle reminders` — and the session exits the way any
197
- completion makes it exit.
194
+ 3. **The idle ladder** — a turn ended cleanly without a completion, pi is waiting for
195
+ input, and the task is still open. The plugin re-sends the assignment and counts the
196
+ rung. The idle that finds the bound `idleReminders` names already spent reports
197
+ `failed` — head `no completion after <n> idle reminders` — and the session exits the
198
+ way any completion makes it exit.
198
199
  4. **`recycle{outcome}`** — the host is tearing the session down. The plugin settles an
199
200
  unsettled task with the host's outcome first, then stops and exits pi.
200
201
 
@@ -205,7 +206,8 @@ assignment the task arrived with — the same header, task text and attachment p
205
206
  injection carried, under one line saying the previous turn ended without a completion —
206
207
  and never the role prose, which is already in the session's context. Its images are not
207
208
  re-attached: the paths travel as text, so the same bytes are not put into the context
208
- twice. A new envelope for the same task restarts the count.
209
+ twice. A new envelope for the same task restarts the count, and so does any turn of the
210
+ session's own (see the idle claim below).
209
211
 
210
212
  `head` is a single line, capped at 200 characters; it matches what the client puts in
211
213
  `out_head` and what the receipt carries. Each task has one source for it: the `text` of
@@ -221,13 +223,39 @@ outcome the socket could not carry is queued and flushed after the next `hello`,
221
223
  flush's answer is the handover that ends the process. A completion the host refused leaves
222
224
  the process running, so an exit never loses the task.
223
225
 
224
- The last report is one observation with `agent: "idle"`, sent
225
- after the completion is acknowledged and before the process leaves. The completion settles
226
- the row from the tuple the client holds, and that tuple still reads `running` when the
227
- finishing turn was the last heartbeat. Nothing observes the process afterwards, so without
228
- this report an exited session keeps saying `running`. The plugin skips it when the last
229
- beat was already idle, and a refused settled observation does not hold up the exit the
230
- completion earned.
226
+ The completion is the plugin's last report, and a session that has completed one answers
227
+ no further beat and no `probe`. The terminal agent state is the client's own write: the
228
+ `detach` frame that follows retires the task-free session, and that path feeds
229
+ `AgentGone` and publishes the row (`retire_idle_locked`,
230
+ `crates/onlyne-client/src/session/dispatch/retire.rs`). A process on its way out states
231
+ no phase for itself.
232
+
233
+ ### The idle claim
234
+
235
+ A session is idle only while it waits for user input. Every other moment reads
236
+ `running`: a turn in flight, a queued steering or follow-up message, a retry, a
237
+ compaction, and work a background-task extension took off the agent loop.
238
+
239
+ The plugin asks pi instead of assuming. `ctx.isIdle()` answers whether a run, a
240
+ compaction or a queued continuation is still open, `ctx.hasPendingMessages()` answers
241
+ whether input is already on its way, and a probe that is missing or throws reads as
242
+ `running`. `agent_settled` is where the claim normally lands, because pi fires that
243
+ event only after a run has fully settled; the end of one turn is a different moment, and
244
+ the 10-second beat re-derives the phase from pi on every tick, so a stale one cannot
245
+ survive a run that started again.
246
+
247
+ A background-task extension changes the question. `bg_run` and its siblings return at
248
+ once and the work continues in a child process, so pi waits for input while the session's
249
+ task is still in flight. The plugin recognises the extension by the tools it registered
250
+ and asks its EventBus service for the live task list; a task in `running` status holds
251
+ the session at `running` and holds the ladder off until that task reports a terminal
252
+ state. On a session without the extension there is no tool to recognise, no query, and
253
+ nothing to wait for.
254
+
255
+ The ladder counts one idle episode, not a task's whole life. Any turn of the session's
256
+ own zeroes the count, so work that resumed and ran again starts the bound over; the one
257
+ turn the ladder's own reminder wakes belongs to the episode that reminder belongs to,
258
+ and the bound stays reachable.
231
259
 
232
260
  ## 5. Relay guard
233
261
 
@@ -386,12 +414,12 @@ path the client's daemon bound, read when the environment carried none, §8).
386
414
  | `reconnecting in 4000ms` in a loop | the client is down or the socket was replaced | `onlyne --server-root … roles` |
387
415
  | `ready refused: internal: unknown session for …` | the plugin mounted and reported for a task the client never staged (normal when pi is started by hand outside a task) | start pi under the client, not by hand |
388
416
  | `assign` never arrives | the client's `session_command` did not spawn pi, or `inject` was dropped | the client log for the spawn line; `/onlyne status` for the capability set |
389
- | ledger stays `in_flight` | no completion yet: no turn has run (the injected message has not executed), or the ladder is still reminding it (`idleReminders`) | the pi session file for the `onlyne-assign` entry and the reminders injected after it; the `onlyne` panel for `reminder n of m`; `/onlyne status` for the task and phase |
417
+ | ledger stays `in_flight` | no completion yet: no turn has run (the injected message has not executed), or the ladder is still reminding it (`idleReminders`). On pi-onlyne 1.2.1 against pi 0.87 an envelope carrying an image left nothing injected at all: pi read the flat `ImageContent` this plugin now sends, refused the nested part the older plugin built, and took the task text down with it | the pi session file for the `onlyne-assign` entry and the reminders injected after it; the `onlyne` panel for `reminder n of m`; `/onlyne status` for the task and phase; for that refusal, the pane's `Extension "<runtime>" error` line and, on 1.2.2, the plugin's `attachment carried no base64 data or no media type` log |
390
418
  | `onlyne_complete` answers `relay guard: missing handoff to: …` | the workspace's spec (or a `relay.toml` standing in for it) names a role this session never sent to | routine notices appear in the `onlyne` panel; stderr keeps refusals such as `relay guard from …`, socket errors, timeouts and framing faults; `required=…` names the policy; `relay guard: missing handoff …` names the delivered set |
391
419
  | `hello … forbidden` / connection closed right after `hello` | the mount role does not match the client's role | `hello.args.mount.role` vs the workspace's role |
392
420
  | `frame_too_large` | a body above 8 MiB | only reachable through an oversize outbound image; the ceiling is the core's |
393
421
  | tools missing | `pi.registerTool` is absent in that pi version | `/onlyne status`; the capability table above |
394
- | session reads `idle` again after `exited` | a turn-end heartbeat landed after the completion, moving the agent dimension back | the session log for the report order after `completion`; the plugin stops reporting for a completed task, and the client's own `delivery` survives either way |
422
+ | session reads `idle` while a background task still runs | the background-task extension is absent, or its EventBus service did not answer the status query in time, so the plugin cannot see the work it left running | the `[pi-onlyne]` log line for the background probe; `bg_status` in that same pi session names the live task |
395
423
  | the supervisor board lists no tabs | no live session reported a pane: the adapter predates the report, or this pi is not inside an Orca pane | `onlyne --server-root … sessions --json` for `projection.observed.host.orca.pane_key`; `env \| grep ORCA_` inside the pane |
396
424
 
397
425
  `/onlyne status` prints the live state (`connected`, `socket`, `role`, `sessionId`,
@@ -418,3 +446,277 @@ ONLYNE_BACKEND=fake BIN_DIR=target/debug bash crates/onlyne-testkit/e2e/pi-live.
418
446
  After sourcing the shared helpers, the case exports `ONLYNE_BACKEND=exec`, so the client
419
447
  spawns pi itself with a stdin pipe it keeps open for the life of the session. The
420
448
  agent's own output lands in `<ws>/.onlyne/logs/session-<task>.log`.
449
+
450
+ # 中文说明 / Chinese Translation
451
+
452
+ ## pi-onlyne — onlyne 的 pi 代理适配器
453
+
454
+ 此 pi 扩展让一个 pi 进程承载一个 onlyne 角色会话。它连接到 `<role workspace>/.onlyne/run/s`,使用 `crates/onlyne-adapter/PROTOCOL.md` 中的适配器协议,并按照 `hello → welcome → assign → work → complete → detach` 驱动会话。此处不运行 Rust 代码:协议基于 Node 的 `node:net` 重新实现,使用手写的四字节长度前缀 JSON 编解码器,运行时没有 npm 依赖。
455
+
456
+ 在一个 onlyne 会话之外,扩展不会执行任何操作。客户端会向其启动的每个进程注入 `ONLYNE_ROLE`、`ONLYNE_SESSION_ID` 和 `ONLYNE_TASK_ID`(`crates/onlyne-client/src/session/dispatch.rs`)。任一变量缺失时,这就是一个普通的 pi 会话:插件不注册任何内容,也不打开任何内容。
457
+
458
+ ```
459
+ pi session (spawned by onlyne-client)
460
+ │ env: ONLYNE_ROLE / ONLYNE_SESSION_ID / ONLYNE_TASK_ID
461
+ │ .pi/onlyne.json: { "enabled": true, "watch": { "autoStart": true } }
462
+ ▼
463
+ hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{role,session,task_id,pid}}
464
+ ◀── welcome{role, prose, generation, server, host_capabilities}
465
+ ├─ prose ──► pi context, once (custom message, no turn)
466
+ ├─ report.ready ──► the barrier the task payload waits behind
467
+ ◀── assign{envelope, prose, task_id, generation}
468
+ ├─ task text (+ image path) ──► pi user message (deliverAs:"followUp")
469
+ ├─ assign_ack{accepted:true}
470
+ ├─ report.heartbeat{agent} — `running` per turn and every 10s while a task is live,
471
+ │ `idle` only while pi waits for input; every beat re-derives it from pi
472
+ ├─ the session waits for input with the task open and no `onlyne_complete` ──►
473
+ │ the idle ladder: the same message again (at most `idleReminders` times),
474
+ │ then `failed` and out
475
+ ├─ report.complete{outcome, head} — the ledger's terminal fact, and the last report
476
+ │ └─ the client's answer is the handover: pi is asked to shut down, then detaches
477
+ ├─ probe ──► one heartbeat
478
+ ◀── recycle ──► complete (if unsettled) → stop → pi exits
479
+ └─ detach{reason} when pi shuts down
480
+ ```
481
+
482
+ ## 1. 安装
483
+
484
+ 该插件是一个 pi 包:`package.json` 声明了 `pi.extensions: ["./src/index.ts"]`,因此 pi 会直接加载 TypeScript 源码,无需构建步骤。
485
+
486
+ ### 使用生成的工作区(常规路径)
487
+
488
+ `onlyne server generate` 会将 `[server].agent_package` 复制到 `<ws>/.onlyne/agent/<pkg-name>/`,并将该包以相对于设置文件本身的路径写入 `.pi/settings.json`:`../.onlyne/agent/<pkg-name>`(`crates/onlyne-server/src/generate.rs`)。pi 0.85.1 仅加载这种写法。项目的 `packages` 路径相对于包含设置文件的目录(`<ws>/.pi`)解析,因此 `../` 形式可到达 `<ws>/.onlyne/agent/<pkg-name>`。裸的 `.onlyne/agent/<pkg-name>` 条目会解析到 `<ws>/.pi/.onlyne/agent/<pkg-name>`,并将该包列在列表中,但不会加载它。监管器会启动生成的工作区,扩展也会随其一同分发,无需进行全局安装。
489
+
490
+ ```toml
491
+ # spec.toml
492
+ [server]
493
+ agent_package = "/abs/path/to/plugins/onlyne-agent-pi" # read once, at generate time
494
+ ```
495
+
496
+ ```bash
497
+ onlyne server generate --root <server-root> --out <dir>
498
+ ```
499
+
500
+ 生成的 `.pi/settings.json` 随后会包含:
501
+
502
+ ```json
503
+ { "packages": ["../.onlyne/agent/onlyne-agent-pi"] }
504
+ ```
505
+
506
+ `pi list` 会在“项目包”下列出该条目。要验证实际加载情况,可以让复制后的 `index.ts` 抛出异常并观察失败。
507
+
508
+ ### 手动安装(不使用生成器)
509
+
510
+ ```bash
511
+ cp -R plugins/onlyne-agent-pi <ws>/.onlyne/agent/onlyne-agent-pi
512
+ printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings.json
513
+ ```
514
+
515
+ ### 从 npm 安装
516
+
517
+ ```bash
518
+ pi install npm:pi-onlyne # user-level: every pi process on this box loads it
519
+ ```
520
+
521
+ 已发布的 npm 包是 `pi-onlyne`;`pi install npm:pi-onlyne@<version>` 可以固定一个版本。此安装路径也会覆盖普通的交互式会话,在这些会话中扩展保持不活动状态(没有 `ONLYNE_ROLE`,因此没有适配器)。角色工作区无需全局安装即可使用面板:上面的文件级复制或 `onlyne server generate` 会将插件的作用域限定到承载角色的工作区。
522
+
523
+ ### 一次性使用/测试
524
+
525
+ ```bash
526
+ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
527
+ ```
528
+
529
+ ### 开关文件
530
+
531
+ `<cwd>/.pi/onlyne.json`(参见 `onlyne.json.example`):
532
+
533
+ | 键 | 默认值 | 效果 |
534
+ | --- | --- | --- |
535
+ | `enabled` | `true` | `false` 会为此工作区关闭扩展 |
536
+ | `watch.autoStart` | `true` | `false` 会注册工具,但在 `/onlyne connect` 前不打开套接字 |
537
+ | `idleReminders` | `2` | 一次空闲期内重新发送任务分配信息的次数,随后任务失败(§4);`0` 表示第一次没有完成的空闲就会使任务失败 |
538
+
539
+ 文件缺失时,所有项均使用默认值。文件格式错误时,会在 stderr 打印一条警告并保留默认值:拼写错误不能使角色在无提示的情况下停用。客户端不读取此文件(计划 §11 已将旧的就绪门控降级为生成时模板建议),因此只有此扩展会读取它;键结构仍采用模板所带的结构。
540
+
541
+ 无需其他设置。工作区在 `spec.toml` 中的 `session_command` 已按任务启动 `pi`(`["pi", "--session-id", "{session}"]`),客户端也会注入此扩展所依赖的环境变量。
542
+
543
+ ## 2. 能力
544
+
545
+ `hello` 帧声明此插件实际实现的功能:
546
+
547
+ | 能力 | 声明 | 此处的含义 |
548
+ | --- | --- | --- |
549
+ | `register` | 始终 | 在 `welcome` 之后发送 `session_register{session_id, task_id, generation, pid, title}` |
550
+ | `report` | 始终 | `report.ready` / `report.heartbeat` / `report.complete` |
551
+ | `inject` | 当 `pi.sendUserMessage` 存在时 | 任务正文通过 `assign` 到达,并作为 pi 用户消息注入 |
552
+ | `recycle` | 始终 | `recycle` 会在任务尚未确定最终状态时将其确定,然后停止插件并退出 pi |
553
+
554
+ pi API 缺失时会发生什么,以及主机随后如何处理:
555
+
556
+ | 缺口 | 检测方式 | 行为 |
557
+ | --- | --- | --- |
558
+ | 没有 `registerTool`(较旧的 pi) | 在 `session_start` 时探测 | 不注册任何工具;协议路径不受影响,`/onlyne status` 仍可使用 |
559
+ | 没有 `sendUserMessage` | 在 `session_start` 时探测 | 从能力列表中移除 `inject`,主机通过 `config_get{key:"stdin:<text>"}` 传递任务,插件通过仍然可用的通道注入该内容 |
560
+ | 没有 `sendMessage` | 探测 | 来自 `welcome` 的角色说明不会作为上下文注入;任务本身仍会送达 |
561
+ | 没有 `appendEntry` | 探测 | 不记录 `onlyne-assign` / `onlyne-complete` 会话条目 |
562
+ | 没有 `ui.setStatus` | 保护性检测 | 跳过页脚状态行 |
563
+ | 没有 `ui.setWidget` | 保护性检测 | 常规通知继续通过页脚状态行和 stderr 上的 `[pi-onlyne]` 行传递 |
564
+ | 没有 `ctx.shutdown` | 保护性检测 | `recycle` 和任务完成仍会确定任务的最终状态;进程会保持运行,等待操作员关闭 |
565
+
566
+ ### 活动面板
567
+
568
+ 当主机报告存在 UI(`ctx.hasUI` 在 TUI 和 RPC 模式下为 `true`,在 print 和 JSON 模式下为 `false`),且 `ctx.ui.setWidget` 可用时,常规 onlyne 通知会显示在编辑器上方、键为 `onlyne` 的面板中。页眉显示角色、连接状态、代次、当前任务 id 和阶段。其下最多显示六条事件,按从新到旧排列:入站帧使用 `<=`,出站帧使用 `=>`,警告使用 `!!`,状态变化使用 `..`,重复投递使用 `~~`。重复的相同事件会合并为一行,并带 `xN`;面板最多容纳八行,每行上限为 96 个单元,`session_shutdown` 会清空面板。
569
+
570
+ ## 3. 工具
571
+
572
+ 仅在 onlyne 会话内注册。
573
+
574
+ ### `onlyne_send{to, text, kind?, image?}`
575
+
576
+ 通过 `send` 帧发送一个信封。`kind: "note"`(默认值)是自由文本,不携带 `op_id`。`kind: "task"` 将工作移交给一个角色,因此携带 `o-<uuid>` 幂等键和新的 `causality.task`。`image` 是 `png/jpeg/gif/webp` 文件的绝对路径:插件读取该文件,进行 base64 编码,并将其作为 `body.image` 附加。核心将该文件限制为 2 MiB,并接受四种 mime 类型。
577
+
578
+ ### `onlyne_complete{outcome?, text?, force?, reason?}`
579
+
580
+ 以明确结果结束当前任务(默认为 `done`,或为 `failed`)。这是通向 `done` 的唯一路径:没有调用它的轮次会先收到提醒,随后失败(§4)。非空 `text` 会原样成为账本的 `head`:空白折叠为一行,文本在 200 个字符处截断。缺失或为空的 `text` 不携带摘要,因此完成时回退到最后一条助手文本。该调用也会结束会话进程:客户端确认完成报告后(见 §4),插件会通过 `ctx.shutdown()` 请求 pi 关闭。pi 0.85.1 没有工具结果的 `terminate` 处理。工作区带有中继策略时(§5),`force: true` 搭配非空 `reason`,可明确绕过会话仍需完成的交接。
581
+
582
+ ### `onlyne_handoff{to, text, image?}`
583
+
584
+ 将此会话的任务交予其族的下一个节点。插件发送一个 `handoff` 帧,指明会话当前持有的任务,主机随后在 `to` 之下创建一个子任务:子任务将本任务命名为其 `parent_task`,位置向后一跳,并携带相同的族 id、跳数预算、origin、deadline 和 labels。工具结果会给出子任务 id 及其跳数。客户端拒绝会作为工具错误原样返回。`image` 是发送工具所接受的同类绝对 `png/jpeg/gif/webp` 路径。causality 指明跳数预算的任务分配信息,会在其注入的页眉行中写明当前跳数与预算。`onlyne_send{kind: "task"}` 是到达角色的另一种方式:该信封在第 0 跳启动一个新族。
585
+
586
+ ## 4. 结果规则
587
+
588
+ `onlyne_complete` 是通向 `done` 的唯一路径。插件为每个任务发送一次完成报告,在以下事件中第一个发生时发送:
589
+
590
+ 1. **`onlyne_complete`**——模型给出明确结果(默认为 `done`,或为 `failed` / `cancelled`)。同一任务后续的完成调用会被拒绝,不会再次报告。其非空 `text` 就是 head。
591
+ 2. **出错的轮次**——该轮次以模型提供方错误结束(`stopReason: "error"`)。这本身即可证明出错,因此插件立即报告 `failed`,并将错误作为 head。
592
+ 3. **空闲阶梯**——某轮次正常结束但未完成,pi 正在等待输入,且任务仍处于打开状态。插件重新发送任务分配信息,并计入当前级数。当某次空闲发现 `idleReminders` 指定的上限已经用尽时,会报告 `failed`——head 为 `no completion after <n> idle reminders`——并以完成时相同的方式退出会话。
593
+ 4. **`recycle{outcome}`**——主机正在拆除会话。插件先使用主机给出的结果确定尚未确定状态的任务,然后停止并退出 pi。
594
+
595
+ 有两种情况不会确定最终状态:任务已经分配,但其轮次尚未运行,此时注入消息尚未执行,立即完成会声称完成了从未发生的工作;以及阶梯仍有下一级可走的空闲。阶梯重新发送任务最初到达时的分配信息,使用与注入时相同的页眉、任务文本和附件路径,并附上一行说明上一轮次结束时没有完成。阶梯不会重新发送角色说明,因为角色说明已存在于会话上下文中。其图像不会再次附加:路径以文本形式传递,因此相同的字节不会两次进入上下文。同任务的新信封会重新开始计数。
596
+
597
+ `head` 是单行文本,上限为 200 个字符;它与客户端写入 `out_head` 的内容以及回执携带的内容一致。每个任务的 `head` 只有一个来源:显式 `onlyne_complete` 调用携带的 `text`、失败轮次所报告的错误,或者阶梯自身的文本行。对于完全没有携带文本的 `onlyne_complete` 调用,最后一条助手文本作为回退;此类调用之后说出的句子不能替代调用已经交付的内容,也不会被其他任何内容读取。
598
+
599
+ 已报告的完成会结束会话进程。`report.complete` 以请求形式发出;客户端仅在完成会话记录的处理、确认投递并写入 `Completion` 信封后才回复。插件在该回复到达时请求 pi 关闭。套接字无法传输的结果会进入队列,并在下一次 `hello` 之后刷新;该次刷新收到的回复即为结束进程的交接。主机拒绝的完成会让进程继续运行,因此进程退出不会导致任务丢失。
600
+
601
+ 完成报告是插件发出的最后一份报告;已经完成任务的会话不再回应心跳,也不再回应 `probe`。终态的 agent 维度由客户端自己写入:随后到达的 `detach` 会退役这个已无任务的会话,该路径会喂入 `AgentGone` 并发布这一行(`retire_idle_locked`,`crates/onlyne-client/src/session/dispatch/retire.rs`)。正在离开的进程不为自己声明阶段。
602
+
603
+ ### 空闲判定
604
+
605
+ 只有会话正在等待用户输入时,它才是空闲的。其余时刻一律读作 `running`:正在运行的轮次、已排队的 steer 或 follow-up 消息、重试、压缩,以及被后台任务扩展移出 agent 循环的工作。
606
+
607
+ 插件向 pi 查询,而不是自行假设。`ctx.isIdle()` 回答是否还有运行、压缩或排队中的续跑,`ctx.hasPendingMessages()` 回答输入是否已经在路上;缺失或抛错的探针一律读作 `running`。`agent_settled` 通常是空闲声明落地的时刻,因为 pi 只在一次运行彻底结算之后才触发该事件;单个轮次的结束是另一个时刻。每 10 秒的心跳会在每次触发时重新向 pi 推导阶段,因此重新开始的运行不会留下过期的空闲读数。
608
+
609
+ 后台任务扩展改变了这个问题。`bg_run` 及其同类工具立即返回,工作继续在子进程中运行,于是 pi 在会话任务仍在进行时等待输入。插件通过该扩展注册的工具识别它,并向它的 EventBus 服务查询存活任务列表;处于 `running` 状态的任务会让会话保持 `running`,并让阶梯退后,直到该任务报告终态。没有安装该扩展的会话没有可识别的工具、没有查询,也就没有可等待的东西。
610
+
611
+ 阶梯统计的是一次空闲期,而不是任务的整个生命。会话自己启动的任何一轮次都会把计数清零,因此恢复运行并继续工作之后,上限重新开始;阶梯自己的提醒唤醒的那一轮次属于该提醒所属的空闲期,上限依然可以达到。
612
+
613
+ ## 5. 中继守卫
614
+
615
+ 会话即使没有交接任何工作,也可能报告 `done`。中继守卫用于防止此类意外:一个 bench 会话叙述了自身的进度,在待办事项尚未处理时调用了 `onlyne_complete`,下游的 `writer` 一直等待一个从未发送的交接。中继守卫仅判定投递事实——某个角色是否收到消息——不会判断已发送文本的形式或质量。
616
+
617
+ 策略位于插件的 `package.json` 旁边,因此会随生成工作区所加载的副本一同分发:生成工作区中为 `<ws>/.onlyne/agent/onlyne-agent-pi/relay.toml`,手动安装中为 `relay.toml`。
618
+
619
+ ```toml
620
+ relay_required = ["writer"] # these roles must have received a handoff
621
+ relay_required_count = 2 # legacy alias of relay_count: this many distinct downstream roles
622
+ ```
623
+
624
+ `relay_count` 是规范计数键。`relay_required_count` 是其旧版别名,也是 `relay.toml` 自身采用的拼写。列表键和计数键同时存在时,`relay_required` 优先。
625
+
626
+ 策略应放在规范中,而不是供应商目录中。`onlyne generate --force` 会重写此包被供应到的副本,并连同其中的手写 `relay.toml` 一同处理,因此一个 `[[client]]` 条目只需声明一次策略,客户端就会将其注入所启动的每个会话进程:
627
+
628
+ ```toml
629
+ [[client]]
630
+ role = "planner"
631
+ relay_count = 2 # this many distinct downstream roles
632
+ relay_required = ["writer"] # these roles must have received a handoff
633
+ ```
634
+
635
+ 来源的优先级为 `environment > relay.toml > none`:`ONLYNE_RELAY_REQUIRED`(列表,逗号分隔)和 `ONLYNE_RELAY_COUNT`(计数,十进制)是客户端根据上述条目填充的变量;只有环境变量完全未指明策略时,才读取 `package.json` 旁边的 `relay.toml`;两个来源均未指定策略时,不启用守卫。规范同时命名两个变量时,两者都会注入,因此列表仍然优先。手写 `relay.toml` 仍是手动安装的应急入口,适用于规范始终未声明策略的机器;被环境变量遮蔽的文件会被直接忽略。变量已设置但无法解析时,会在 stderr 上报告并忽略,此时由文件决定策略。
636
+
637
+ | | |
638
+ | --- | --- |
639
+ | 默认 | 两个来源均未指明策略:不启用守卫,完成路径沿用守卫加入前此插件随包提供的路径 |
640
+ | 证据 | 此会话自身成功执行的 `onlyne_send` 调用所触达的角色,包括 `note` 和 `task`;被拒绝的信封不计入任何角色 |
641
+ | 拒绝 | `onlyne_complete` 抛出 `onlyne: relay guard: missing handoff to: writer (…)`,并指出缺少的内容及解除方法 |
642
+ | 拒绝之后 | 不会报告任何内容,不会将结果排队,也不会分离:会话保持挂载状态;交接发出后,同一调用即可通过 |
643
+ | 列表模式 | 每个列出的角色都必须按字面出现在已送达集合中 |
644
+ | 计数模式 | 不同的下游角色;发送到当前角色自身或发回分配任务的角色不计入 |
645
+ | 范围 | 此会话自身在进程内存中的发送记录:重新连接会保留记录,重启后的会话从空状态开始,不推测早先进程发送过什么 |
646
+ | 豁免 | `force: true` 搭配非空 `reason`;仅在守卫拒绝时发挥作用 |
647
+ | 审计 | 获豁免的完成报告,其账本 head 以 `relay-guard-forced: <reason>` 开头;若调用携带了模型的 `text`,则将其附加在后面 |
648
+ | 不受守卫保护 | 插件在没有模型参与时报告的结果:出错的轮次、阶梯导致的失败,以及 `recycle{outcome}` |
649
+
650
+ `relay.toml` 是 TOML 的封闭子集:扁平的 `key = value` 行、上述两个键、由双引号字符串构成的单行数组,以及 `#` 注释。超出此范围的内容会在 stderr 上警告并被忽略。它不采用 `.onlyne/config.toml`:客户端使用 `deny_unknown_fields` 解析该文件,因此在其中加入插件键会阻止客户端启动。
651
+
652
+ 没有生效策略时,`force` 和 `reason` 不起作用。
653
+
654
+ ## 6. 协议说明与差异
655
+
656
+ 下面每项都是对 `PROTOCOL.md` 的有意解读,或是在已发布客户端上测得的行为。
657
+
658
+ - **报告序列基线。** 插件自身的 `report` 序列从 1000 开始,而非 1。客户端将自己的调度事件(`created`、资源附加、`ready`)记入同一个 `(generation, seq)` 水位,归约器会静默丢弃任何小于或等于该水位的报告(`crates/onlyne-session/src/reconcile/`)。从 1 开始的插件序列会丢失最初几条观测。版本控制的其他部分均遵循规范。
659
+ - **`observed` 是一个完整的 `Observation`。** `report.heartbeat` 携带状态元组(`version`、`generation_live`、`isolate_after`、`terminate_after`、`mismatch_count`、`agent`、`delivery`、`resource`、`recovery`),而不是 `{"state": "running"}` 简写:主机对其进行反序列化,覆盖客户端拥有的六个键,并仅应用 `is_legal` 接受的元组。此插件拥有 `agent` 维度(轮次钩子)、`resource` 声明——其进程在记录了挂载操作的窗格中处于活动状态——以及 `host` 绑定。它没有 `delivery`、`recovery`、`generation_live`、`isolate_after`、`terminate_after` 或 `mismatch_count` 的观测依据:在应用元组之前,客户端会依据自己的意图队列、归约器历史和角色配置重写全部六项,因此此插件在这些位置发送的内容不会被读取。任务结果和公开视图均不通过元组传输。
660
+ - **`ready` 每次连接报告一次。** 主机自身的交接路径(`crates/onlyne-client/src/session/dispatch/delivery.rs::hand_session`)已会在客户端为挂载插件暂存会话时报告 `ready`,因此主机会将插件的第二次报告视为空操作。插件仍会发送:在任何工作存在之前完成挂载的插件正是就绪屏障所涵盖的情况,而且该报告只占用一个帧。
661
+ - **从不发送 `cluster_ref`。** 此插件代表本地角色,不代表聚合角色;出于相同原因,Rust 一侧会将该字段设为 `skip_serializing_if`,使其缺省。
662
+ - **使用心跳响应 `probe`**,遵循 `PROTOCOL.md` 中“`probe` 声明新的资源观测”的说明。
663
+ - **仅当 `config_get` 以 `stdin:` 开头时,才将其读取为任务正文**,这是 `PROTOCOL.md` 为缺少 `inject` 的插件记录的重载。任何其他键都会记录到日志并被忽略,不会被误读。
664
+ - **`frame_too_large` / `bad_frame`**:正文过大时,会在写入任何字节之前拒绝;分帧错误会关闭连接并重新连接。正文损坏后,分帧无法重新同步,这也与 `crates/onlyne-frame/src/lib.rs` 得出的结论相同。
665
+ - **投递具备幂等性;任务不具备。** 去重键是信封 id。同一投递出现两次只会产生一次注入,以及带有 `reason: "duplicate"` 的确认;为已在运行的任务创建的新信封,会作为另一条消息到达该会话——工作记录保留其计数器和其中继账本,仅其“自该指令以来的轮次”看门狗重新启动。客户端为每个信封生成新的 uuid,因此 `duplicate` 仅会在真正重新提供任务时触发。
666
+
667
+ - **窗格绑定(Orca 标签页)。** 在 Orca 窗格内,插件会在每次心跳中通过报告 `Observation` 里的 `observed.host.orca.pane_key` 报告其运行所在的窗格(`crates/onlyne-session/src/host.rs`),并在环境变量指明时一并报告 `tab_id` / `leaf_id` 和终端 `handle`。绑定是从*内部*继承的,从不猜测:Orca 窗格会将其 `ORCA_PANE_KEY` / `ORCA_TAB_ID` / `ORCA_LEAF_ID` / `ORCA_TERMINAL_HANDLE` 导出到所启动的命令中(测于 2026-09-11,Orca 1.4.198),客户端会将自己的环境传递给会话命令。因此,窗格内的进程是唯一能够从内部说明 onlyne 会话位于哪个窗格的组件;pi 下游的任何内容都无法恢复此信息。在窗格之外,`host` 键会完全缺失:普通终端中的 pi 会报告不含 host 字段的观测,不会生成空窗格字段。
668
+ - **不会为此向工作区写入任何内容。** 已不再有绑定声明文件:绑定信息随客户端已经镜像的观测一同传递。不存在声明文件,因为没有组件创建它,工作区的缓存目录也不会被触及。这使 `integrations/orca-plugin` 无需读取任何路径即可将标签页轴限定为真实会话,也让监管器仍能说明一个*已结束*会话的运行位置:`report.complete` 会继续携带 `host`。
669
+
670
+ ## 7. 配置参考
671
+
672
+ | 环境变量 | 是否必需 | 效果 |
673
+ | --- | --- | --- |
674
+ | `ONLYNE_ROLE` | 是 | 挂载角色 |
675
+ | `ONLYNE_SESSION_ID` | 是 | 挂载的会话 id;已发布客户端中的 `session_id` 等于 `task_id` |
676
+ | `ONLYNE_TASK_ID` | 是 | 此进程承载的任务;驱动 `session_register` 和初始的 `ready` |
677
+ | `ONLYNE_SOCKET` | 否 | 客户端为此工作区提供服务的套接字,会注入所启动的每个会话进程;变量未设置时,插件读取标记 `<cwd>/.onlyne/run/socket` 以获取守护进程公布的路径,并回退到 `<cwd>/.onlyne/run/s` |
678
+ | `ONLYNE_RELAY_REQUIRED` | 否 | 角色规范中的 `relay_required`,以逗号连接:守卫的列表模式(§5) |
679
+ | `ONLYNE_RELAY_COUNT` | 否 | 角色规范中的 `relay_count`:守卫的计数模式,仅在列表为空时决定结果(§5) |
680
+ | `ORCA_PANE_KEY` | 否 | 此进程的运行位置(`<tab_id>:<leaf_id>`),每次心跳通过 `observed.host.orca.pane_key` 上报;在 Orca 窗格之外未设置,因此该字段会缺失 |
681
+ | `ORCA_TAB_ID` / `ORCA_LEAF_ID` | 否 | 单独的窗格 id;仅设置窗格键本身时,会对该键进行解析 |
682
+ | `ORCA_TERMINAL_HANDLE` | 否 | 终端句柄,在窗格键旁以 `host.orca.handle` 上报,其值即 `orca terminal switch` 所使用的值 |
683
+
684
+ 需要知道的常量:插件每 10 s 发送一次心跳(`heartbeat_timeout_ms` 为 30 s),为 `hello` 留出 5 s,每个请求留出 30 s,并按 1/2/4/8/16/30 s 的阶梯重新连接。
685
+
686
+ 插件会读取自己的三个文件:`<cwd>/.pi/onlyne.json`(开关,§1)、`package.json` 旁边的 `relay.toml`(中继策略的回退来源,仅在客户端没有注入策略时读取,§5),以及 `<cwd>/.onlyne/run/socket`(标记客户端守护进程所绑定的套接字路径,当环境变量未携带该路径时读取,§8)。
687
+
688
+ ## 8. 故障排除
689
+
690
+ | 症状 | 原因 | 检查 |
691
+ | --- | --- | --- |
692
+ | `[pi-onlyne] session …` 始终未出现 | 三个环境变量中缺少一个,或 `enabled` 为 false | `env \| grep ONLYNE_`;`cat .pi/onlyne.json` |
693
+ | `socket error: connect ENOENT …/.onlyne/run/s` | 此工作区没有运行 `onlyne-client run` | 启动客户端,或 `onlyne-client status` |
694
+ | 深层工作区出现 `socket error: connect EINVAL …/.onlyne/run/s` | macOS 为 `sun_path` 提供 104 字节,因此超过 103 的套接字路径会被拒绝;生成的角色工作区嵌套在服务器根目录下三层,过长的根路径会使规范拼写超过此上限。客户端会从临时目录下的短路径为此类工作区提供服务,并将其公布在 `<workspace>/.onlyne/run/socket` | 通过 `onlyne-client status` 查找 `onlyne: client running … socket <path>` 这一行,其中会指明所服务的路径;还需查看带有 `socket = <path>` 的客户端日志行;`cat <workspace>/.onlyne/run/socket` 包含同一路径,环境变量未注入任何值时,插件会连接该路径 |
695
+ | `reconnecting in 4000ms` 持续循环 | 客户端已停止,或套接字已被替换 | `onlyne --server-root … roles` |
696
+ | `ready refused: internal: unknown session for …` | 插件完成挂载并为一个客户端从未暂存的任务进行了报告(在任务之外手动启动 pi 时属于正常情况) | 在客户端下启动 pi,不手动启动 |
697
+ | `assign` 始终未到达 | 客户端的 `session_command` 未启动 pi,或 `inject` 已被移除 | 在客户端日志中查看启动行;通过 `/onlyne status` 查看能力集合 |
698
+ | 账本保持 `in_flight` | 尚未完成:还没有轮次运行(注入消息尚未执行),或者阶梯仍在提醒(`idleReminders`)。在 pi-onlyne 1.2.1 对上 pi 0.87 时,带图片附件的信封会什么都没注入:pi 读取本插件现在送出的扁平 `ImageContent`,拒掉旧插件构造的嵌套 part,任务正文随之一起丢失 | 在 pi 会话文件中查看 `onlyne-assign` 条目及其后注入的提醒;通过 `onlyne` 面板查看 `reminder n of m`;通过 `/onlyne status` 查看任务和阶段;要找那次拒绝,看窗格里的 `Extension "<runtime>" error` 行,以及 1.2.2 上插件的 `attachment carried no base64 data or no media type` 日志 |
699
+ | `onlyne_complete` 回复 `relay guard: missing handoff to: …` | 工作区的规范(或替代该规范的 `relay.toml`)指定了一个此会话从未向其发送的角色 | 常规通知会出现在 `onlyne` 面板中;stderr 会保留拒绝消息,例如 `relay guard from …`、套接字错误、超时和分帧错误;`required=…` 指明策略;`relay guard: missing handoff …` 指明已送达的集合 |
700
+ | `hello … forbidden`/在 `hello` 后立即关闭连接 | 挂载角色与客户端角色不匹配 | `hello.args.mount.role` 与工作区角色 |
701
+ | `frame_too_large` | 正文超过 8 MiB | 仅可通过超大的出站图像达到;此上限来自核心 |
702
+ | 工具缺失 | 该 pi 版本中不存在 `pi.registerTool` | `/onlyne status`;上方的能力表 |
703
+ | 后台任务仍在运行时,会话显示 `idle` | 未安装后台任务扩展,或其 EventBus 服务未在时限内回应状态查询,插件看不到自己留下的运行中工作 | `[pi-onlyne]` 日志中关于后台探针的行;同一 pi 会话中的 `bg_status` 会列出存活任务 |
704
+ | 监管器面板未列出任何标签页 | 没有活动会话报告窗格:适配器早于该报告功能,或此 pi 不在 Orca 窗格内 | `onlyne --server-root … sessions --json` 中的 `projection.observed.host.orca.pane_key`;在窗格内执行 `env \| grep ORCA_` |
705
+
706
+ `/onlyne status` 会打印实时状态(`connected`、`socket`、`role`、`sessionId`、`generation`、`agentState`、`tasks`、`pendingCompletion`、`lastError`、计数器),`/onlyne connect` / `/onlyne disconnect` 可手动打开和关闭套接字。
707
+
708
+ ## 9. 开发
709
+
710
+ ```bash
711
+ cd plugins/onlyne-agent-pi
712
+ node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard, socket path
713
+ ```
714
+
715
+ 除非 `target/debug/onlyne-client` 和 `onlyne-server` 存在,否则 `src/agent.live.test.mjs` 会自行跳过。`crates/onlyne-testkit/e2e/pi-live.sh` 是端到端用例:pi 缺失或没有可用的模型凭据时,它会跳过(退出码 0);否则,它会通过真实客户端运行一个真实任务,直到 `acked`。
716
+
717
+ ```bash
718
+ cd ../..
719
+ ONLYNE_BACKEND=fake BIN_DIR=target/debug bash crates/onlyne-testkit/e2e/pi-live.sh
720
+ ```
721
+
722
+ 该用例加载共享辅助函数后,会导出 `ONLYNE_BACKEND=exec`,因此客户端会自行启动 pi,并使用在整个会话期间保持打开的 stdin 管道。代理自身的输出会写入 `<ws>/.onlyne/logs/session-<task>.log`。
package/README.zh.md CHANGED
@@ -19,12 +19,13 @@ hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{r
19
19
  ├─ prose ──► 注入 pi 上下文一次(custom message,不触发 turn)
20
20
  ├─ report.ready ──► 载荷等待的那道 barrier
21
21
  ◀── assign{envelope, prose, task_id, generation}
22
- ├─ 任务文本(含图片路径)──► pi user message(deliverAs:"followUp")
22
+ ├─ task text (+ image path) ──► pi user message(deliverAs:"followUp")
23
23
  ├─ assign_ack{accepted:true}
24
- ├─ report.heartbeat{running|idle} —— 每个 turn,以及任务存续期间每 10 秒
25
- ├─ turn 结束却没有 `onlyne_complete` ──► idle 阶梯:同一条消息再注入
26
- │ (最多 `idleReminders` 次),之后 `failed` 并退出
27
- ├─ report.complete{outcome, head} —— ledger 的终态事实
24
+ ├─ report.heartbeat{agent} —— 每个 turn 以及任务存续期间每 10 秒发 `running`,
25
+ │ 只有 pi 等待输入时才发 `idle`;每次心跳都重新向 pi 推导
26
+ ├─ 任务仍开着、pi 正在等待输入却没有 `onlyne_complete` ──► idle 阶梯:
27
+ │ 同一条消息再注入(最多 `idleReminders` 次),之后 `failed` 并退出
28
+ ├─ report.complete{outcome, head} —— ledger 的终态事实,也是最后一份报告
28
29
  │ └─ client 的应答就是交接点:插件据此让 pi 退出,随后 detach
29
30
  ├─ probe ──► 一条 heartbeat
30
31
  ◀── recycle ──► (未终态则先 complete)→ 停插件 → pi 退出
@@ -97,7 +98,7 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
97
98
  | --- | --- | --- |
98
99
  | `enabled` | `true` | `false` 时该工作区禁用扩展 |
99
100
  | `watch.autoStart` | `true` | `false` 时注册工具但不建连接,需 `/onlyne connect` |
100
- | `idleReminders` | `2` | turn 结束却空闲时重发任务的次数上限(§4);`0` 表示第一次空闲就判失败 |
101
+ | `idleReminders` | `2` | 一次空闲期内重发任务的次数上限(§4);`0` 表示第一次空闲就判失败 |
101
102
 
102
103
  文件缺失即取各自默认值。文件格式错误时打印一行警告,并保留默认值:一个笔误不该静默关掉一个
103
104
  role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为 generate 期模板提示),所以
@@ -171,16 +172,17 @@ hop 预算时,注入的标题行写明 hop 与预算。`onlyne_send{kind: "tas
171
172
  任务的第二次 completion 被拒(不重报)。`text` 非空时即 head,原样写出。
172
173
  2. **turn 出错** —— turn 以 provider 错误告终(`stopReason: "error"`)。这本身就是证据,插件立即
173
174
  报 `failed`,错误信息当 head。
174
- 3. **idle 阶梯** —— turn 干净结束却没有 completion,任务还开着。插件重发任务并记一次;当空闲发现
175
- `idleReminders` 给的次数已经用完,就报 `failed`(head 为 `no completion after <n> idle
176
- reminders`),并像任何一次 completion 一样退出 session。
175
+ 3. **idle 阶梯** —— 某轮干净结束却没有 completion,pi 正在等待输入,任务还开着。插件重发任务并
176
+ 记一次;当空闲发现 `idleReminders` 给的次数已经用完,就报 `failed`(head 为 `no completion
177
+ after <n> idle reminders`),并像任何一次 completion 一样退出 session。
177
178
  4. **`recycle{outcome}`** —— 宿主拆 session。插件先按宿主给的 outcome 结算未终态的任务,再停
178
179
  插件并退出 pi。
179
180
 
180
181
  两种情况都不结算:任务已投递但还没跑过任何 turn(注入的消息尚未执行,这时报终态就是撒谎),
181
182
  以及阶梯还有余额的那次空闲。阶梯重发的是任务到手时的那条消息——同样的头部、任务文本和附件路径,
182
183
  外加一行说明上一轮没有 completion——但不重发 role prose,它已经在上下文里;图片也不重挂,
183
- 路径以文本出现,同样的字节不会进上下文两次。同一任务收到新 envelope 时计数重来。
184
+ 路径以文本出现,同样的字节不会进上下文两次。同一任务收到新 envelope 时计数重来;session 自己
185
+ 跑起来的任何一轮也把计数清零(见下面的空闲判定)。
184
186
 
185
187
  `head` 恒为单行、上限 200 字符,与 client 写入 `out_head` 和回执携带的内容一致。每个任务的 head
186
188
  只有一个来源:显式 `onlyne_complete` 带的 `text`(有则原样采用)、出错 turn 报的错误,或阶梯自己
@@ -192,11 +194,28 @@ hop 预算时,注入的标题行写明 hop 与预算。`onlyne_send{kind: "tas
192
194
  让 pi 退出。socket 当时送不出去的 outcome 会被记住,并在下一次 `hello` 后补发,那次补发的
193
195
  应答就是结束进程的交接点。被宿主拒掉的 completion 不会让进程退出,任务不会因为退出而丢失。
194
196
 
195
- 最后一条上报只说 agent 这一维:`agent: "idle"` 的观测,发在 completion 被
196
- ack 之后、进程退出之前。completion 是按 client 手里的元组结算 session 行的,而收尾那一轮
197
- 就是最后一次 heartbeat 时,这个元组读到的仍是 `running`;此后没有任何东西再观测这个进程,
198
- 所以缺了这条上报,已退出的 session 会一直说 `running`。最后一次心跳本来就是 idle 时,插件
199
- 跳过这条;已结算的观测被拒,也不拖着 completion 挣来的那次退出不走。
197
+ completion 是插件最后一份上报;已经交掉任务的 session 不再发心跳,也不再应答 `probe`。终态的
198
+ agent 维度由 client 自己写:随后到达的 `detach` 会退役这个已无任务的 session,该路径会喂入
199
+ `AgentGone` 并发布这一行(`retire_idle_locked`,
200
+ `crates/onlyne-client/src/session/dispatch/retire.rs`)。正在离开的进程不给自己声明阶段。
201
+
202
+ ### 空闲判定
203
+
204
+ 只有 session 在等待用户输入时才算空闲。其余时刻一律读作 `running`:正在跑的 turn、已排队的
205
+ steer 或 follow-up 消息、重试、压缩,以及被后台任务扩展移出 agent 循环的工作。
206
+
207
+ 插件向 pi 查询,不自行假设。`ctx.isIdle()` 回答是否还有运行、压缩或排队中的续跑,
208
+ `ctx.hasPendingMessages()` 回答输入是否已经在路上;探针缺失或抛错一律读作 `running`。空闲声明通常
209
+ 落在 `agent_settled`,因为 pi 只在一次运行彻底结算之后才触发它;单轮 turn 结束是另一件事。每
210
+ 10 秒的心跳每次触发都重新向 pi 推导阶段,因此重新开始的运行不会留下过期的空闲读数。
211
+
212
+ 后台任务扩展改变了这个问题。`bg_run` 与同类工具立即返回,工作继续在子进程里跑,于是 pi 在任务
213
+ 仍在进行时就等待输入。插件通过该扩展注册的工具认出它,再向它的 EventBus 服务查存活任务列表;
214
+ 处于 `running` 的任务会把 session 按在 `running` 上,也让阶梯退后,直到该任务报出终态。没有装
215
+ 这个扩展的 session 没有工具可认、没有查询,也没有东西要等。
216
+
217
+ 阶梯算的是一次空闲期,不是任务的一生。session 自己跑起来的任何一轮都把计数清零,于是恢复运行、
218
+ 继续干活之后,上限重新开始;阶梯自己的提醒唤醒的那一轮属于该提醒所属的空闲期,上限依然能达到。
200
219
 
201
220
  ## 5. 接力守卫
202
221
 
@@ -335,7 +354,7 @@ stderr 告警并忽略,把机会让回文件。
335
354
  | `hello` 后立刻 `forbidden` / 断连 | mount role 与 client 的 role 不一致 | `hello.args.mount.role` 对该工作区的 role |
336
355
  | `frame_too_large` | 正文超过 8 MiB | 只会由超限的出站图片触发;上限来自核心 |
337
356
  | 工具缺失 | 该 pi 版本没有 `pi.registerTool` | `/onlyne status`;对照上面的能力表 |
338
- | 会话在 `exited` 之后又回到 `idle` | completion 之后又落进一条 turn-end heartbeat,把 agent 维搬了回去 | 看 session 日志里 `completion` 之后的 report 顺序;插件对已完成任务不再上报,而 client 自己的 `delivery` 两条都会保住 |
357
+ | 后台任务还在跑,会话却显示 `idle` | 没装后台任务扩展,或它的 EventBus 服务没在时限内回答状态查询,插件看不到自己留下的运行中工作 | `[pi-onlyne]` 日志里后台探针那一行;同一个 pi session 里的 `bg_status` 会列出存活任务 |
339
358
  | supervisor 看板一个 tab 都不列 | 没有 live session 上报过 pane:适配器版本早于这条上报,或这个 pi 不在 Orca pane 里 | `onlyne --server-root … sessions --json` 看 `projection.observed.host.orca.pane_key`;在 pane 里跑 `env \| grep ORCA_` |
340
359
 
341
360
  `/onlyne status` 打印实时状态(`connected`、`socket`、`role`、`sessionId`、`generation`、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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",
@@ -52,12 +52,6 @@ test("hostile input stays inside render bounds", () => {
52
52
  for (const line of lines) assert.ok(Array.from(line).length <= MAX_WIDTH, line);
53
53
  });
54
54
 
55
- test("identical state gives identical output", () => {
56
- const activity = createActivity({ clock }).set({ role: "planner", connection: "connected" });
57
- activity.note("in", "build it");
58
- assert.deepEqual(activity.lines(), activity.lines());
59
- });
60
-
61
55
  test("default render shows the newest SHOW_EVENTS events", () => {
62
56
  const activity = createActivity({ clock });
63
57
  for (let index = 0; index < SHOW_EVENTS + 4; index += 1) activity.note("state", `event ${index}`);
@@ -65,6 +65,7 @@ test("a real onlyne-client answers hello with a welcome", { skip: !hasBinaries }
65
65
  status: () => {},
66
66
  welcome: () => {},
67
67
  isIdle: () => true,
68
+ waitingForInput: async () => true,
68
69
  exit: () => {},
69
70
  };
70
71
  const logs = [];