pi-onlyne 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # pi-onlyne — the onlyne agent adapter for pi
2
2
 
3
- This pi extension makes one pi process serve one onlyne role session. It connects to
4
- `<role workspace>/.onlyne/run/s`, speaks the adapter protocol in
3
+ This pi extension makes one pi process serve one onlyne role session. It connects to the
4
+ socket that workspace's client serves — `<digest>.sock` in the machine-level runtime
5
+ directory (`/tmp/onlyne-<uid>/`, `ONLYNE_RUNTIME_DIR` overriding it), never a path inside
6
+ the tree — speaks the adapter protocol in
5
7
  `crates/onlyne-adapter/PROTOCOL.md`, and drives a session through
6
8
  `hello → welcome → assign → work → complete → detach`. No Rust code runs here: the
7
9
  protocol is reimplemented on Node's `node:net`, with a hand-written four-byte
@@ -19,17 +21,19 @@ pi session (spawned by onlyne-client)
19
21
  ▼
20
22
  hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{role,session,task_id,pid}}
21
23
  ◀── welcome{role, prose, generation, server, host_capabilities}
22
- ├─ prose ──► pi context, once (custom message, no turn)
24
+ ├─ prose ──► one `onlyne-role-prose` section of the system prompt, once
23
25
  ├─ report.ready ──► the barrier the task payload waits behind
24
- ◀── assign{envelope, prose, task_id, generation}
25
- ├─ task text (+ image path) ──► pi user message (deliverAs:"followUp")
26
+ ◀── assign{envelope, prose, text, attachments, task_id, generation}
27
+ ├─ prose, when the welcome did not already deliver it ──► the same section
28
+ ├─ text ──► pi user message (deliverAs:"followUp"), byte for byte; `body.image`
29
+ │ rides as a pi image part, and `attachments` names files the client wrote
26
30
  ├─ assign_ack{accepted:true}
27
31
  ├─ report.heartbeat{agent} — `running` per turn and every 10s while a task is live,
28
32
  │ `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 owns the turn-end rule: a turn that ends without a completion
34
+ │ earns one nudge, and the second such ending settles the delivery
35
+ ◀── nudge{task_id, text} ──► pi user message, byte for byte (the client's sentence)
36
+ ├─ report.complete{outcome, head, details, files} — the ledger's terminal fact, and the last report
33
37
  │ └─ the client's answer is the handover: pi is asked to shut down, then detaches
34
38
  ├─ probe ──► one heartbeat
35
39
  ◀── recycle ──► complete (if unsettled) → stop → pi exits
@@ -105,7 +109,6 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
105
109
  | --- | --- | --- |
106
110
  | `enabled` | `true` | `false` turns the extension off for this workspace |
107
111
  | `watch.autoStart` | `true` | `false` registers the tools but opens no socket until `/onlyne connect` |
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 |
109
112
 
110
113
  A missing file means every default. A malformed file prints one warning on stderr and
111
114
  keeps the defaults: a typo must not silently disable a role. The client does not read
@@ -125,7 +128,7 @@ The `hello` frame declares what this plugin actually implements:
125
128
  | --- | --- | --- |
126
129
  | `register` | always | `session_register{session_id, task_id, generation, pid, title}` after `welcome` |
127
130
  | `report` | always | `report.ready` / `report.heartbeat` / `report.complete` |
128
- | `inject` | when `pi.sendUserMessage` exists | the payload arrives as `assign` and is injected as a pi user message |
131
+ | `inject` | when `pi.sendUserMessage` exists | the delivery arrives as `assign{…, text}` and `text` is injected as a pi user message, unchanged |
129
132
  | `recycle` | always | `recycle` settles the task if it is unsettled, then stops the plugin and exits pi |
130
133
 
131
134
  What happens when a pi API is missing, and what the host does then:
@@ -133,8 +136,8 @@ What happens when a pi API is missing, and what the host does then:
133
136
  | gap | detection | behaviour |
134
137
  | --- | --- | --- |
135
138
  | no `registerTool` (older pi) | probed at `session_start` | no tools are registered; the protocol path is unaffected, and `/onlyne status` still works |
136
- | no `sendUserMessage` | probed at `session_start` | `inject` is dropped from the capability list, so the host delivers the task through `config_get{key:"stdin:<text>"}`, which the plugin injects through whatever channel remains |
137
- | no `sendMessage` | probed | the role prose from `welcome` is not injected as context; the task itself still arrives |
139
+ | no `sendUserMessage` | probed at `session_start` | `inject` is dropped from the capability list, so the host delivers the task through `config_get{key:"stdin:<delivery text>"}`, which the plugin injects through whatever channel remains |
140
+ | no `sections` on `before_agent_start` | guarded at each run | the role prose reaches no instruction layer — the run was offered no prompt sections to write into; one stderr line says so, and the delivery text still arrives |
138
141
  | no `appendEntry` | probed | no `onlyne-assign` / `onlyne-complete` session entries are recorded |
139
142
  | no `ui.setStatus` | guarded | the footer status line is skipped |
140
143
  | no `ui.setWidget` | guarded | routine notices continue through the footer status line and the `[pi-onlyne]` stderr line |
@@ -146,7 +149,13 @@ When the host reports a UI (`ctx.hasUI`, true in the TUI and RPC modes, false in
146
149
 
147
150
  ## 3. Tools
148
151
 
149
- Registered only inside an onlyne session.
152
+ Registered only inside an onlyne session, with the schemas the MCP face carries
153
+ (`docs/v2-CONTRACT.md`, "3b's interface: the `tools` mount"): one obligation vocabulary
154
+ that two drives mount.
155
+
156
+ Each result is one plain sentence — the recipient for a send or a handoff, the outcome
157
+ for a completion. A tool result is model-visible, and nothing about the plugin's own
158
+ bookkeeping belongs in it.
150
159
 
151
160
  ### `onlyne_send{to, text, kind?, image?}`
152
161
 
@@ -154,67 +163,66 @@ Sends one envelope on the `send` frame. `kind: "note"` (the default) is free tex
154
163
  carries no `op_id`. `kind: "task"` hands work to a role, so it carries an `o-<uuid>`
155
164
  idempotency key and a fresh `causality.task`. `image` is an absolute path to a
156
165
  png/jpeg/gif/webp file: the plugin reads it, base64-encodes it and attaches it as
157
- `body.image`. The core caps that at 2 MiB and accepts four mime types.
158
-
159
- ### `onlyne_complete{outcome?, text?, force?, reason?}`
160
-
161
- Ends the current task with an explicit outcome (`done` by default, or `failed`). It is the
162
- only path to `done`: a turn that ends without it is reminded and then failed (§4). A
163
- non-empty `text` becomes the ledger `head` verbatim: whitespace collapses to one line and
164
- the text stops at 200 characters. An absent or blank `text` carries no summary, so the
165
- completion falls back to the last assistant text. The call also ends the session's
166
- process: once the client has acknowledged the completion report (see §4), the plugin asks
167
- pi to shut down through `ctx.shutdown()`. pi 0.85.1 has no tool-result `terminate`
168
- handling. When the workspace carries a relay policy (§5), `force: true` with a non-empty
169
- `reason` is the deliberate way past a handoff the session still owes.
166
+ `body.image`. The core caps that at 2 MiB and accepts four mime types. The result is
167
+ `sent to <role>`.
168
+
169
+ ### `onlyne_complete{outcome, summary, details?, files?}`
170
+
171
+ Ends the current task with an explicit outcome — the proto's `Outcome`, so `done`,
172
+ `failed`, `cancelled` or `blocked`. `summary` is the display line, and it becomes the
173
+ ledger `head` verbatim: whitespace collapses to one line and the text stops at 200
174
+ characters. An empty `summary` carries no display line, so the head falls back to the last
175
+ assistant text. `details` is the full result and `files` the absolute paths it names; both
176
+ ride the `report.complete` frame unchanged and are what the next hop and the originator
177
+ receive, with the client holding the ceiling and refusing an oversize body with its own
178
+ sentence (§3c of the contract). The call also ends the session's process: once the client
179
+ has acknowledged the completion report (§4), the plugin asks pi to shut down through
180
+ `ctx.shutdown()`. pi 0.85.1 has no tool-result `terminate` handling. The result is
181
+ `reported <outcome>`.
170
182
 
171
183
  ### `onlyne_handoff{to, text, image?}`
172
184
 
173
185
  Hands this session's task on to the next hop of its family. The plugin sends one `handoff`
174
186
  frame naming the task the session currently holds, and the host mints one child task for
175
187
  `to` under it: the child names this task as its `parent_task`, sits one hop further along,
176
- and carries the same family id, hop budget, origin, deadline and labels. The tool's result
177
- names the child task id and its hop. A client refusal comes back as the tool's error,
178
- verbatim. `image` is the same absolute png/jpeg/gif/webp path the send tool takes. An
179
- assignment whose causality names a hop budget states the hop and the budget in its
180
- injected header line. `onlyne_send{kind: "task"}` is the other way to reach a role: that
181
- envelope starts a family of its own at hop 0.
188
+ and carries the same family id, hop budget, origin, deadline and labels. A client refusal
189
+ comes back as the tool's error, verbatim. `image` is the same absolute png/jpeg/gif/webp
190
+ path the send tool takes. The child's id and its hop stay protocol data — the result is
191
+ `handed on to <role>`, and the child is proven from the ledger row the host writes, not
192
+ from a sentence this plugin printed. The delivery text is the client's own rendering
193
+ (sender, body, attachment paths) and this plugin injects it as it stands.
194
+ `onlyne_send{kind: "task"}` is the other way to reach a role: that envelope starts a family
195
+ of its own at hop 0.
182
196
 
183
197
  ## 4. Outcome rules
184
198
 
185
199
  `onlyne_complete` is the only path to `done`. The plugin sends one completion per task,
186
200
  at the first of these events:
187
201
 
188
- 1. **`onlyne_complete`** — the model gives an explicit outcome (`done` by default, or
189
- `failed` / `cancelled`). A later completion for the same task is refused (not
190
- re-reported). Its non-empty `text` is the head.
202
+ 1. **`onlyne_complete`** — the model gives an explicit outcome: `done`, `failed`,
203
+ `cancelled` or `blocked`. A later completion for the same task is refused (not
204
+ re-reported). Its non-empty `summary` is the head.
191
205
  2. **An errored turn** — the turn ended with a provider error (`stopReason: "error"`).
192
206
  That is proof on its own, so the plugin reports `failed` at once, with the error as
193
- the head.
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.
199
- 4. **`recycle{outcome}`** — the host is tearing the session down. The plugin settles an
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
200
210
  unsettled task with the host's outcome first, then stops and exits pi.
201
211
 
202
- Two things settle nothing: a task that was assigned but whose turn has not run yet (the
203
- injected message has not executed, so completing now would claim work that never
204
- happened), and an idle the ladder still has a rung for. The ladder re-sends the
205
- assignment the task arrived with — the same header, task text and attachment paths the
206
- injection carried, under one line saying the previous turn ended without a completion —
207
- and never the role prose, which is already in the session's context. Its images are not
208
- re-attached: the paths travel as text, so the same bytes are not put into the context
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).
212
+ A turn that ends cleanly without a completion settles nothing here. The client owns that
213
+ rule (`docs/v2-CONTRACT.md`, "3c. One turn-end rule"): it counts the endings, sends its
214
+ own sentence as a `nudge`, and decides what a delivery that never reports becomes. The
215
+ plugin hands the sentence to pi and answers only that it did, so the wording, the count
216
+ and the settlement have one owner rather than two. A task whose injected message has not
217
+ run a turn is left alone whatever a settle signal says: reporting now would claim work
218
+ that never happened.
211
219
 
212
220
  `head` is a single line, capped at 200 characters; it matches what the client puts in
213
- `out_head` and what the receipt carries. Each task has one source for it: the `text` of
214
- the explicit `onlyne_complete` call when that call carried one, the error a failed turn
215
- reported, or the ladder's own line. The last assistant text is the fallback for an
216
- `onlyne_complete` call that carried no text at all — a sentence spoken after such a call
217
- cannot replace what the call handed over, and nothing else reads it.
221
+ `out_head` and what the receipt carries. Each task has one source for it: the `summary`
222
+ of the explicit `onlyne_complete` call when that call carried one, or the error a failed
223
+ turn reported. The last assistant text is the fallback for a call whose `summary` is
224
+ empty — a sentence spoken after such a call cannot replace what the call handed over, and
225
+ nothing else reads it.
218
226
 
219
227
  A reported completion ends the session's process. `report.complete` goes out as a request,
220
228
  and the client answers it only after it has settled the session row, acked the delivery
@@ -248,78 +256,11 @@ A background-task extension changes the question. `bg_run` and its siblings retu
248
256
  once and the work continues in a child process, so pi waits for input while the session's
249
257
  task is still in flight. The plugin recognises the extension by the tools it registered
250
258
  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.
259
-
260
- ## 5. Relay guard
259
+ the session at `running`, and the one report the plugin owes at a turn end — the failure
260
+ it witnessed itself — waits that out. On a session without the extension there is no tool
261
+ to recognise, no query, and nothing to wait for.
261
262
 
262
- A session can hand no work over and still report `done`. That is the accident the guard
263
- closes: a bench session narrated its progress, called `onlyne_complete` with its todos
264
- untouched, and the downstream writer waited for a handoff that was never sent. The guard
265
- judges delivery facts only — whether a role was reached — and never the shape or quality
266
- of the text that was sent.
267
-
268
- The policy lives next to the plugin's `package.json`, so it travels inside the copy a
269
- generated workspace loads: `<ws>/.onlyne/agent/onlyne-agent-pi/relay.toml` in a generated
270
- workspace, `relay.toml` in a manual installation.
271
-
272
- ```toml
273
- relay_required = ["writer"] # these roles must have received a handoff
274
- relay_required_count = 2 # legacy alias of relay_count: this many distinct downstream roles
275
- ```
276
-
277
- `relay_count` is the canonical count key. `relay_required_count` is its legacy alias, the spelling `relay.toml` itself uses. `relay_required` wins when both the list and the count keys are present.
278
-
279
- The policy belongs in the spec, not in the vendor directory. `onlyne generate --force`
280
- rewrites the copy this package is vendored into and takes a hand-written `relay.toml`
281
- with it, so a `[[client]]` entry states the policy once and the client injects it into
282
- every session process it spawns:
283
-
284
- ```toml
285
- [[client]]
286
- role = "planner"
287
- relay_count = 2 # this many distinct downstream roles
288
- relay_required = ["writer"] # these roles must have received a handoff
289
- ```
290
-
291
- The sources rank `environment > relay.toml > none`: `ONLYNE_RELAY_REQUIRED` (the list,
292
- comma-separated) and `ONLYNE_RELAY_COUNT` (the count, decimal) are the variables the
293
- client fills from the entry above; a `relay.toml` beside `package.json` is read only
294
- when the environment names no policy at all; and neither one means no guard. Both
295
- variables are injected when the spec names both, so the list still wins. A hand-written
296
- `relay.toml` remains the manual installation's escape hatch — for a box whose spec
297
- never states the policy — and a file shadowed by the environment is ignored outright. A
298
- variable that is set but unparsable is reported on stderr and ignored, which gives the
299
- file its turn.
300
-
301
- | | |
302
- | --- | --- |
303
- | default | neither source names a policy: no guard, and the completion path is the one this plugin shipped before the guard existed |
304
- | evidence | the roles this session's own successful `onlyne_send` calls reached, `note` and `task` alike; a refused envelope counts for nothing |
305
- | refusal | `onlyne_complete` throws `onlyne: relay guard: missing handoff to: writer (…)`, naming what is missing and how to clear it |
306
- | after a refusal | nothing is reported, queued or detached: the session stays mounted, and the same call lands once the handoff has gone out |
307
- | list mode | every named role must be in the delivered set, literally |
308
- | count mode | distinct downstream roles; a send to this role itself or back to the role that assigned the task is not one |
309
- | scope | this session's own sends, in process memory: a reconnect keeps them, a restarted session starts empty rather than guessing at what an earlier process sent |
310
- | waiver | `force: true` with a non-empty `reason`; it only matters when the guard refuses |
311
- | audit | a waived completion's ledger head starts with `relay-guard-forced: <reason>`, followed by the model's `text` when the call carried one |
312
- | not guarded | outcomes the plugin reports without the model: an errored turn, the idle ladder's failure, and `recycle{outcome}` |
313
-
314
- `relay.toml` is a closed subset of TOML: flat `key = value` lines, the two keys above,
315
- one-line arrays of double-quoted strings, `#` comments. Anything outside that warns on
316
- stderr and is ignored. It is deliberately not `.onlyne/config.toml`: the client parses
317
- that file with `deny_unknown_fields`, so a plugin key there would stop the client from
318
- starting at all.
319
-
320
- `force` and `reason` are inert when no policy is in force.
321
-
322
- ## 6. Protocol notes and deviations
263
+ ## 5. Protocol notes and deviations
323
264
 
324
265
  Each item below is either a deliberate reading of `PROTOCOL.md` or a behaviour measured on
325
266
  the shipped client.
@@ -327,8 +268,17 @@ the shipped client.
327
268
  - **Report sequence base.** The plugin's own `report` sequence starts at 1000, not 1. The
328
269
  client stamps its own dispatch events (`created`, resource attach, `ready`) into the
329
270
  same `(generation, seq)` watermark, and the reducer silently drops any report at or
330
- below it (`crates/onlyne-session/src/reconcile/`). A plugin sequence starting at 1
331
- would lose its first observations. Everything else about the versioning is per spec.
271
+ below it (`crates/onlyne-client/src/reconcile/`). A plugin sequence starting at 1
272
+ would lose its first observations. **One plugin, one counter:** every task the session
273
+ holds beats off the same sequence, because the client takes `row.seq + 1` for its own
274
+ event on a row between two of that task's beats, and a counter that advanced one per
275
+ task per round would land on exactly that number. The gate is per task row all the
276
+ same, so each task record also carries the `seq` of its last report (`task.lastSeq`,
277
+ visible as `taskSeqs` in `/onlyne status`), and an allocation is clamped above it:
278
+ `A@1001, B@1002, A@1003` is the shape that works, and no task can be handed back a seq
279
+ its own row has already accepted. Rounds never overlap either — a beat asked for while
280
+ one is writing folds into it and buys one more pass, not a second snapshot of the same
281
+ tick. Everything else about the versioning is per spec.
332
282
  - **`observed` is a full `Observation`.** `report.heartbeat` carries the state
333
283
  tuple (`version`, `generation_live`, `isolate_after`, `terminate_after`,
334
284
  `mismatch_count`, `agent`, `delivery`, `resource`, `recovery`), not
@@ -351,23 +301,23 @@ the shipped client.
351
301
  reason.
352
302
  - **`probe` is answered with a heartbeat**, per `PROTOCOL.md`'s "a `probe` declares fresh
353
303
  resource observations".
354
- - **`config_get` is read as a task body only when it starts with `stdin:`**, which is the
355
- overload `PROTOCOL.md` documents for plugins without `inject`. Any other key is logged
356
- and ignored, never misread.
304
+ - **`config_get` is read as a delivery text only when it starts with `stdin:`**, which is
305
+ the overload `PROTOCOL.md` documents for plugins without `inject`: the key carries the
306
+ same rendered bytes an `assign` puts in `text`, and they are injected as they stand.
307
+ Any other key is logged and ignored, never misread.
357
308
  - **`frame_too_large` / `bad_frame`**: an oversize body is refused before any byte is
358
309
  written, and a framing fault closes the connection and reconnects. Framing cannot
359
310
  resynchronise after a corrupt body, which is the same conclusion
360
- `crates/onlyne-frame/src/lib.rs` reaches.
311
+ `crates/onlyne-wire/src/frame.rs` reaches.
361
312
  - **Deliveries are idempotent; tasks are not.** The dedup key is the envelope id. The
362
313
  same delivery twice gets one injection and an ack with `reason: "duplicate"`, and a
363
314
  new envelope for a task that is already running reaches that session as another
364
- message — the work record keeps its counters and its relay ledger, and only its
365
- "turns since this instruction" watchdog restarts. The client mints a fresh uuid per
366
- envelope, so `duplicate` fires on a genuine re-offer and on nothing else.
315
+ message. The client mints a fresh uuid per envelope, so `duplicate` fires on a genuine
316
+ re-offer and on nothing else.
367
317
 
368
318
  - **Pane binding (Orca tabs).** Inside an Orca pane the plugin reports the pane it runs in on every
369
319
  heartbeat, as `observed.host.orca.pane_key` in the report's `Observation`
370
- (`crates/onlyne-session/src/host.rs`), beside `tab_id` / `leaf_id` and the terminal `handle` when
320
+ (`crates/onlyne-client/src/host.rs`), beside `tab_id` / `leaf_id` and the terminal `handle` when
371
321
  the environment names them. The binding is *inherited*, never guessed: an Orca pane exports
372
322
  `ORCA_PANE_KEY` / `ORCA_TAB_ID` / `ORCA_LEAF_ID` / `ORCA_TERMINAL_HANDLE` into the command it
373
323
  starts (measured 2026-09-11, Orca 1.4.198), and the client passes its own environment on to the
@@ -382,16 +332,14 @@ the shipped client.
382
332
  lets a supervisor still say where a *finished* session ran: `report.complete` carries `host`
383
333
  forward.
384
334
 
385
- ## 7. Configuration reference
335
+ ## 6. Configuration reference
386
336
 
387
337
  | env var | required | effect |
388
338
  | --- | --- | --- |
389
339
  | `ONLYNE_ROLE` | yes | the mount role |
390
340
  | `ONLYNE_SESSION_ID` | yes | mounted session id; `session_id` equals `task_id` in the shipped client |
391
341
  | `ONLYNE_TASK_ID` | yes | the task this process serves; drives `session_register` and the initial `ready` |
392
- | `ONLYNE_SOCKET` | no | the socket the client serves for this workspace, injected into every session process it spawns; with the variable unset the plugin reads the marker `<cwd>/.onlyne/run/socket` for the path the daemon published, and falls back to `<cwd>/.onlyne/run/s` |
393
- | `ONLYNE_RELAY_REQUIRED` | no | the role's spec `relay_required`, comma-joined: the guard's list mode (§5) |
394
- | `ONLYNE_RELAY_COUNT` | no | the role's spec `relay_count`: the guard's count mode, which decides only when the list is empty (§5) |
342
+ | `ONLYNE_SOCKET` | no | the socket the client serves for this workspace, injected into every session process it spawns; with the variable unset the plugin finds that client itself, by reading the runtime directory's registration files (`<digest>.json`) for the one whose `root` is this workspace |
395
343
  | `ORCA_PANE_KEY` | no | where this process runs (`<tab_id>:<leaf_id>`), reported on every heartbeat as `observed.host.orca.pane_key`; unset outside an Orca pane, which is why the field is then absent |
396
344
  | `ORCA_TAB_ID` / `ORCA_LEAF_ID` | no | the pane ids separately; the pane key is parsed when only the key itself is set |
397
345
  | `ORCA_TERMINAL_HANDLE` | no | the terminal handle, reported beside the pane key as `host.orca.handle`, and the value `orca terminal switch` takes |
@@ -399,23 +347,23 @@ the shipped client.
399
347
  Constants worth knowing: the plugin heartbeats every 10 s (`heartbeat_timeout_ms` is 30 s),
400
348
  allows 5 s for `hello` and 30 s per request, and reconnects on a 1/2/4/8/16/30 s ladder.
401
349
 
402
- The plugin reads three files of its own: `<cwd>/.pi/onlyne.json` (the switch, §1),
403
- `relay.toml` next to its `package.json` (the relay policy's fallback, read only when the
404
- client injected none, §5), and `<cwd>/.onlyne/run/socket` (the marker naming the socket
405
- path the client's daemon bound, read when the environment carried none, §8).
350
+ The plugin reads one file of its own: `<cwd>/.pi/onlyne.json` (the switch, §1). A second
351
+ read belongs to the machine rather than the tree: when `ONLYNE_SOCKET` is unset the plugin
352
+ lists the machine-level runtime directory for the client registrations that name this
353
+ workspace (§7).
406
354
 
407
- ## 8. Troubleshooting
355
+ ## 7. Troubleshooting
408
356
 
409
357
  | symptom | cause | check |
410
358
  | --- | --- | --- |
411
359
  | `[pi-onlyne] session …` never appears | one of the three env vars is missing, or `enabled` is false | `env \| grep ONLYNE_`; `cat .pi/onlyne.json` |
412
- | `socket error: connect ENOENT …/.onlyne/run/s` | no `onlyne-client run` for this workspace | start the client, or `onlyne-client status` |
413
- | `socket error: connect EINVAL …/.onlyne/run/s` on a deep workspace | macOS gives `sun_path` 104 bytes, so a socket path past 103 is refused; a generated role workspace nests three levels under its server root and a long root carries the canonical spelling over the bound. The client serves such a workspace from a short path under the temporary directory and publishes it in `<workspace>/.onlyne/run/socket` | `onlyne-client status` for the line `onlyne: client running … socket <path>`, which names the served path, plus the client log line carrying `socket = <path>`; `cat <workspace>/.onlyne/run/socket` holds that same path, and the plugin dials it when the environment injected nothing |
360
+ | `socket unresolved: onlyne: no client is registered for <workspace> …` | no `onlyne-client run` for this workspace, so the runtime directory holds no registration whose `root` is this tree | start the client, or `onlyne-client status`; the message names the runtime directory and every registration it did find |
361
+ | `socket unresolved: … N clients there name runtime pi … ambiguous` | more than one registered client runs pi sessions and none of their roots contains this workspace, so there is no single client to dial | name the socket explicitly with `ONLYNE_SOCKET`, or start the client for this workspace |
362
+ | `socket error: connect ENOENT <path>` | the path in the message is not bound: the client that injected it stopped | `onlyne-client status` for the socket it is serving, and the client log line carrying `socket = <path>` |
414
363
  | `reconnecting in 4000ms` in a loop | the client is down or the socket was replaced | `onlyne --server-root … roles` |
415
364
  | `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 |
416
365
  | `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 |
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`) | 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 |
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 |
366
+ | ledger stays `in_flight` | no completion yet: no turn has run (the injected message has not executed), or the turn ended without one and the client has not settled the delivery. 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 any `nudge` sentence injected after it; `/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 |
419
367
  | `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 |
420
368
  | `frame_too_large` | a body above 8 MiB | only reachable through an oversize outbound image; the ceiling is the core's |
421
369
  | tools missing | `pi.registerTool` is absent in that pi version | `/onlyne status`; the capability table above |
@@ -423,14 +371,14 @@ path the client's daemon bound, read when the environment carried none, §8).
423
371
  | 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 |
424
372
 
425
373
  `/onlyne status` prints the live state (`connected`, `socket`, `role`, `sessionId`,
426
- `generation`, `agentState`, `tasks`, `pendingCompletion`, `lastError`, counters), and
427
- `/onlyne connect` / `/onlyne disconnect` open and close the socket by hand.
374
+ `generation`, `agentState`, `seq`, `taskSeqs`, `tasks`, `pendingCompletions`, `lastError`,
375
+ counters), and `/onlyne connect` / `/onlyne disconnect` open and close the socket by hand.
428
376
 
429
- ## 9. Development
377
+ ## 8. Development
430
378
 
431
379
  ```bash
432
380
  cd plugins/onlyne-agent-pi
433
- node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard, socket path
381
+ node --test src/*.test.mjs # framing, protocol, agent state machine, config, socket path
434
382
  ```
435
383
 
436
384
  `src/agent.live.test.mjs` skips itself unless `target/debug/onlyne-client` and
@@ -451,7 +399,7 @@ agent's own output lands in `<ws>/.onlyne/logs/session-<task>.log`.
451
399
 
452
400
  ## pi-onlyne — onlyne 的 pi 代理适配器
453
401
 
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 依赖。
402
+ 此 pi 扩展让一个 pi 进程承载一个 onlyne 角色会话。它连接到该工作区的 client 所服务的 socket——机器级运行目录里的 `<digest>.sock`(`/tmp/onlyne-<uid>/`,`ONLYNE_RUNTIME_DIR` 可覆盖),而不是树内的任何路径——使用 `crates/onlyne-adapter/PROTOCOL.md` 中的适配器协议,并按照 `hello → welcome → assign → work → complete → detach` 驱动会话。此处不运行 Rust 代码:协议基于 Node 的 `node:net` 重新实现,使用手写的四字节长度前缀 JSON 编解码器,运行时没有 npm 依赖。
455
403
 
456
404
  在一个 onlyne 会话之外,扩展不会执行任何操作。客户端会向其启动的每个进程注入 `ONLYNE_ROLE`、`ONLYNE_SESSION_ID` 和 `ONLYNE_TASK_ID`(`crates/onlyne-client/src/session/dispatch.rs`)。任一变量缺失时,这就是一个普通的 pi 会话:插件不注册任何内容,也不打开任何内容。
457
405
 
@@ -462,17 +410,19 @@ pi session (spawned by onlyne-client)
462
410
  ▼
463
411
  hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{role,session,task_id,pid}}
464
412
  ◀── welcome{role, prose, generation, server, host_capabilities}
465
- ├─ prose ──► pi context, once (custom message, no turn)
413
+ ├─ prose ──► one `onlyne-role-prose` section of the system prompt, once
466
414
  ├─ 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")
415
+ ◀── assign{envelope, prose, text, attachments, task_id, generation}
416
+ ├─ prose(仅当 welcome 尚未投递过) ──► 同一个系统提示 section
417
+ ├─ text ──► pi user message(deliverAs:"followUp"),逐字节原样;`body.image`
418
+ │ 作为 pi image part 同行,`attachments` 里的路径是 client 已写好的文件
469
419
  ├─ assign_ack{accepted:true}
470
420
  ├─ report.heartbeat{agent} — `running` per turn and every 10s while a task is live,
471
421
  │ `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
422
+ ├─ 回合结束的规则由 client 拥有:没有 completion 的回合结束换一次 nudge,
423
+ │ 第二次这样的结束就结算这次投递
424
+ ◀── nudge{task_id, text} ──► pi user message,逐字节原样(client 自己的句子)
425
+ ├─ report.complete{outcome, head, details, files} — ledger 的终态事实,也是最后一份报告
476
426
  │ └─ the client's answer is the handover: pi is asked to shut down, then detaches
477
427
  ├─ probe ──► one heartbeat
478
428
  ◀── recycle ──► complete (if unsettled) → stop → pi exits
@@ -534,7 +484,6 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
534
484
  | --- | --- | --- |
535
485
  | `enabled` | `true` | `false` 会为此工作区关闭扩展 |
536
486
  | `watch.autoStart` | `true` | `false` 会注册工具,但在 `/onlyne connect` 前不打开套接字 |
537
- | `idleReminders` | `2` | 一次空闲期内重新发送任务分配信息的次数,随后任务失败(§4);`0` 表示第一次没有完成的空闲就会使任务失败 |
538
487
 
539
488
  文件缺失时,所有项均使用默认值。文件格式错误时,会在 stderr 打印一条警告并保留默认值:拼写错误不能使角色在无提示的情况下停用。客户端不读取此文件(计划 §11 已将旧的就绪门控降级为生成时模板建议),因此只有此扩展会读取它;键结构仍采用模板所带的结构。
540
489
 
@@ -548,7 +497,7 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
548
497
  | --- | --- | --- |
549
498
  | `register` | 始终 | 在 `welcome` 之后发送 `session_register{session_id, task_id, generation, pid, title}` |
550
499
  | `report` | 始终 | `report.ready` / `report.heartbeat` / `report.complete` |
551
- | `inject` | 当 `pi.sendUserMessage` 存在时 | 任务正文通过 `assign` 到达,并作为 pi 用户消息注入 |
500
+ | `inject` | 当 `pi.sendUserMessage` 存在时 | 投递以 `assign{…, text}` 到达,`text` 原样注入为 pi 用户消息 |
552
501
  | `recycle` | 始终 | `recycle` 会在任务尚未确定最终状态时将其确定,然后停止插件并退出 pi |
553
502
 
554
503
  pi API 缺失时会发生什么,以及主机随后如何处理:
@@ -556,8 +505,8 @@ pi API 缺失时会发生什么,以及主机随后如何处理:
556
505
  | 缺口 | 检测方式 | 行为 |
557
506
  | --- | --- | --- |
558
507
  | 没有 `registerTool`(较旧的 pi) | 在 `session_start` 时探测 | 不注册任何工具;协议路径不受影响,`/onlyne status` 仍可使用 |
559
- | 没有 `sendUserMessage` | 在 `session_start` 时探测 | 从能力列表中移除 `inject`,主机通过 `config_get{key:"stdin:<text>"}` 传递任务,插件通过仍然可用的通道注入该内容 |
560
- | 没有 `sendMessage` | 探测 | 来自 `welcome` 的角色说明不会作为上下文注入;任务本身仍会送达 |
508
+ | 没有 `sendUserMessage` | 在 `session_start` 时探测 | 从能力列表中移除 `inject`,主机通过 `config_get{key:"stdin:<投递文本>"}` 传递任务,插件通过仍然可用的通道注入该内容 |
509
+ | 没有可写 section 的提示选项 | 每次 run 保护性检测 | 角色说明进不了指令层——`before_agent_start` 没有可供写 section 的对象;stderr 打一行说明,投递文本照常送达 |
561
510
  | 没有 `appendEntry` | 探测 | 不记录 `onlyne-assign` / `onlyne-complete` 会话条目 |
562
511
  | 没有 `ui.setStatus` | 保护性检测 | 跳过页脚状态行 |
563
512
  | 没有 `ui.setWidget` | 保护性检测 | 常规通知继续通过页脚状态行和 stderr 上的 `[pi-onlyne]` 行传递 |
@@ -569,32 +518,35 @@ pi API 缺失时会发生什么,以及主机随后如何处理:
569
518
 
570
519
  ## 3. 工具
571
520
 
572
- 仅在 onlyne 会话内注册。
521
+ 仅在 onlyne 会话内注册,采用 MCP 面所携带的同一套 schema(`docs/v2-CONTRACT.md`“3b's interface: the `tools` mount”):一份义务词汇,两个驱动共用。
522
+
523
+ 每个结果只有一句平实的话——send 与 handoff 给出发往的角色,complete 给出结果。工具结果是模型可见的,插件自己的簿记没有理由出现在里面。
573
524
 
574
525
  ### `onlyne_send{to, text, kind?, image?}`
575
526
 
576
527
  通过 `send` 帧发送一个信封。`kind: "note"`(默认值)是自由文本,不携带 `op_id`。`kind: "task"` 将工作移交给一个角色,因此携带 `o-<uuid>` 幂等键和新的 `causality.task`。`image` 是 `png/jpeg/gif/webp` 文件的绝对路径:插件读取该文件,进行 base64 编码,并将其作为 `body.image` 附加。核心将该文件限制为 2 MiB,并接受四种 mime 类型。
577
528
 
578
- ### `onlyne_complete{outcome?, text?, force?, reason?}`
529
+ 结果为 `sent to <role>`。
530
+
531
+ ### `onlyne_complete{outcome, summary, details?, files?}`
579
532
 
580
- 以明确结果结束当前任务(默认为 `done`,或为 `failed`)。这是通向 `done` 的唯一路径:没有调用它的轮次会先收到提醒,随后失败(§4)。非空 `text` 会原样成为账本的 `head`:空白折叠为一行,文本在 200 个字符处截断。缺失或为空的 `text` 不携带摘要,因此完成时回退到最后一条助手文本。该调用也会结束会话进程:客户端确认完成报告后(见 §4),插件会通过 `ctx.shutdown()` 请求 pi 关闭。pi 0.85.1 没有工具结果的 `terminate` 处理。工作区带有中继策略时(§5),`force: true` 搭配非空 `reason`,可明确绕过会话仍需完成的交接。
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>`。
581
534
 
582
535
  ### `onlyne_handoff{to, text, image?}`
583
536
 
584
- 将此会话的任务交予其族的下一个节点。插件发送一个 `handoff` 帧,指明会话当前持有的任务,主机随后在 `to` 之下创建一个子任务:子任务将本任务命名为其 `parent_task`,位置向后一跳,并携带相同的族 id、跳数预算、origin、deadline 和 labels。工具结果会给出子任务 id 及其跳数。客户端拒绝会作为工具错误原样返回。`image` 是发送工具所接受的同类绝对 `png/jpeg/gif/webp` 路径。causality 指明跳数预算的任务分配信息,会在其注入的页眉行中写明当前跳数与预算。`onlyne_send{kind: "task"}` 是到达角色的另一种方式:该信封在第 0 跳启动一个新族。
537
+ 将此会话的任务交予其族的下一个节点。插件发送一个 `handoff` 帧,指明会话当前持有的任务,主机随后在 `to` 之下创建一个子任务:子任务将本任务命名为其 `parent_task`,位置向后一跳,并携带相同的族 id、跳数预算、origin、deadline 和 labels。结果是 `handed on to <role>`:子任务 id 与跳数是 ledger 的行,不是模型需要读回的东西。客户端拒绝会作为工具错误原样返回。`image` 是发送工具所接受的同类绝对 `png/jpeg/gif/webp` 路径。跳数与跳数预算留在信封的 `causality` 里,属于协议数据:投递文本由 client 自己渲染(发送方、正文、附件路径),本插件原样注入。`onlyne_send{kind: "task"}` 是到达角色的另一种方式:该信封在第 0 跳启动一个新族。
585
538
 
586
539
  ## 4. 结果规则
587
540
 
588
541
  `onlyne_complete` 是通向 `done` 的唯一路径。插件为每个任务发送一次完成报告,在以下事件中第一个发生时发送:
589
542
 
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。
543
+ 1. **`onlyne_complete`**——模型给出明确结果:`done`、`failed`、`cancelled` 或 `blocked`。同一任务后续的完成调用会被拒绝,不会再次报告。其非空 `summary` 就是 head。
544
+ 2. **出错的轮次**——该轮次以模型提供方错误结束(`stopReason: "error"`)。这本身即可证明出错,因此插件立即报告 `failed`,并将错误作为 head。报告本身要等到 pi 正在等待输入时才发出,因此承载它的那次心跳陈述的是会话真实的阶段。
545
+ 3. **`recycle{outcome}`**——主机正在拆除会话。插件先使用主机给出的结果确定尚未确定状态的任务,然后停止并退出 pi。
594
546
 
595
- 有两种情况不会确定最终状态:任务已经分配,但其轮次尚未运行,此时注入消息尚未执行,立即完成会声称完成了从未发生的工作;以及阶梯仍有下一级可走的空闲。阶梯重新发送任务最初到达时的分配信息,使用与注入时相同的页眉、任务文本和附件路径,并附上一行说明上一轮次结束时没有完成。阶梯不会重新发送角色说明,因为角色说明已存在于会话上下文中。其图像不会再次附加:路径以文本形式传递,因此相同的字节不会两次进入上下文。同任务的新信封会重新开始计数。
547
+ 正常结束、但没有完成的轮次在这里不结算任何东西。那条规则由 client 拥有(`docs/v2-CONTRACT.md` 的「3c. One turn-end rule」):它统计这类结束、把自己的一句话作为 `nudge` 发来,并决定一次始终不上报的投递会变成什么。插件把那句话交给 pi,并且只回答自己交出去了,于是措辞、计数与结算都只有一个拥有者,而不是两个。注入消息尚未跑过任何轮次的任务,无论结算信号说什么都不动:现在就上报等于宣称从未发生的工作已经完成。
596
548
 
597
- `head` 是单行文本,上限为 200 个字符;它与客户端写入 `out_head` 的内容以及回执携带的内容一致。每个任务的 `head` 只有一个来源:显式 `onlyne_complete` 调用携带的 `text`、失败轮次所报告的错误,或者阶梯自身的文本行。对于完全没有携带文本的 `onlyne_complete` 调用,最后一条助手文本作为回退;此类调用之后说出的句子不能替代调用已经交付的内容,也不会被其他任何内容读取。
549
+ `head` 是单行文本,上限为 200 个字符;它与客户端写入 `out_head` 的内容以及回执携带的内容一致。每个任务的 `head` 只有一个来源:显式 `onlyne_complete` 调用携带 `summary` 时就是它,否则是失败轮次报告的错误。完全没有携带 `summary` 的调用,回退到最后一条助手文本;此类调用之后说出的句子不能替代调用已经交付的内容,也不会被其他任何内容读取。
598
550
 
599
551
  已报告的完成会结束会话进程。`report.complete` 以请求形式发出;客户端仅在完成会话记录的处理、确认投递并写入 `Completion` 信封后才回复。插件在该回复到达时请求 pi 关闭。套接字无法传输的结果会进入队列,并在下一次 `hello` 之后刷新;该次刷新收到的回复即为结束进程的交接。主机拒绝的完成会让进程继续运行,因此进程退出不会导致任务丢失。
600
552
 
@@ -606,110 +558,65 @@ pi API 缺失时会发生什么,以及主机随后如何处理:
606
558
 
607
559
  插件向 pi 查询,而不是自行假设。`ctx.isIdle()` 回答是否还有运行、压缩或排队中的续跑,`ctx.hasPendingMessages()` 回答输入是否已经在路上;缺失或抛错的探针一律读作 `running`。`agent_settled` 通常是空闲声明落地的时刻,因为 pi 只在一次运行彻底结算之后才触发该事件;单个轮次的结束是另一个时刻。每 10 秒的心跳会在每次触发时重新向 pi 推导阶段,因此重新开始的运行不会留下过期的空闲读数。
608
560
 
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` 不起作用。
561
+ 后台任务扩展改变了这个问题。`bg_run` 及其同类工具立即返回,工作继续在子进程中运行,于是 pi 在会话任务仍在进行时等待输入。插件通过该扩展注册的工具识别它,并向它的 EventBus 服务查询存活任务列表;处于 `running` 状态的任务会让会话保持 `running`,插件在轮次结束时欠下的那份报告——它自己目睹的失败——也要等它结束。没有安装该扩展的会话没有可识别的工具、没有查询,也就没有可等待的东西。
653
562
 
654
- ## 6. 协议说明与差异
563
+ ## 5. 协议说明与差异
655
564
 
656
565
  下面每项都是对 `PROTOCOL.md` 的有意解读,或是在已发布客户端上测得的行为。
657
566
 
658
- - **报告序列基线。** 插件自身的 `report` 序列从 1000 开始,而非 1。客户端将自己的调度事件(`created`、资源附加、`ready`)记入同一个 `(generation, seq)` 水位,归约器会静默丢弃任何小于或等于该水位的报告(`crates/onlyne-session/src/reconcile/`)。从 1 开始的插件序列会丢失最初几条观测。版本控制的其他部分均遵循规范。
567
+ - **报告序列基线。** 插件自身的 `report` 序列从 1000 开始,而非 1。客户端将自己的调度事件(`created`、资源附加、`ready`)记入同一个 `(generation, seq)` 水位,归约器会静默丢弃任何小于或等于该水位的报告(`crates/onlyne-client/src/reconcile/`)。从 1 开始的插件序列会丢失最初几条观测。**一个插件只有一个计数器:** 会话持有的每个任务都沿用同一条序列发送心跳,因为客户端会在该任务两次心跳之间,为它自己写入该行的事件取 `row.seq + 1`;若每个任务每轮只推进一次,心跳恰好会撞在那个数上。但闸门是按任务行判断的,所以每条任务记录也会记下自己最后一次上报的 `seq`(`task.lastSeq`,在 `/onlyne status` 中以 `taskSeqs` 呈现),新的分配会被抬到它之上:`A@1001、B@1002、A@1003` 才是可用的形状,任何任务都不会被交回自己该行已经接受过的 seq。心跳轮次也不会重叠:一轮正在写时到来的心跳请求会并入这一轮,换来多做一遍,而不是同一刻的第二次快照。版本控制的其他部分均遵循规范。
659
568
  - **`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
569
  - **`ready` 每次连接报告一次。** 主机自身的交接路径(`crates/onlyne-client/src/session/dispatch/delivery.rs::hand_session`)已会在客户端为挂载插件暂存会话时报告 `ready`,因此主机会将插件的第二次报告视为空操作。插件仍会发送:在任何工作存在之前完成挂载的插件正是就绪屏障所涵盖的情况,而且该报告只占用一个帧。
661
570
  - **从不发送 `cluster_ref`。** 此插件代表本地角色,不代表聚合角色;出于相同原因,Rust 一侧会将该字段设为 `skip_serializing_if`,使其缺省。
662
571
  - **使用心跳响应 `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` 仅会在真正重新提供任务时触发。
572
+ - **仅当 `config_get` 以 `stdin:` 开头时,才将其读取为投递文本**,这是 `PROTOCOL.md` 为缺少 `inject` 的插件记录的重载:该键携带的是与 `assign` 的 `text` 相同的已渲染字节,原样注入。任何其他键都会记录到日志并被忽略,不会被误读。
573
+ - **`frame_too_large` / `bad_frame`**:正文过大时,会在写入任何字节之前拒绝;分帧错误会关闭连接并重新连接。正文损坏后,分帧无法重新同步,这也与 `crates/onlyne-wire/src/frame.rs` 得出的结论相同。
574
+ - **投递具备幂等性;任务不具备。** 去重键是信封 id。同一投递出现两次只会产生一次注入,以及带有 `reason: "duplicate"` 的确认;为已在运行的任务创建的新信封,会作为另一条消息到达该会话。客户端为每个信封生成新的 uuid,因此 `duplicate` 仅会在真正重新提供任务时触发。
666
575
 
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 字段的观测,不会生成空窗格字段。
576
+ - **窗格绑定(Orca 标签页)。** 在 Orca 窗格内,插件会在每次心跳中通过报告 `Observation` 里的 `observed.host.orca.pane_key` 报告其运行所在的窗格(`crates/onlyne-client/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
577
  - **不会为此向工作区写入任何内容。** 已不再有绑定声明文件:绑定信息随客户端已经镜像的观测一同传递。不存在声明文件,因为没有组件创建它,工作区的缓存目录也不会被触及。这使 `integrations/orca-plugin` 无需读取任何路径即可将标签页轴限定为真实会话,也让监管器仍能说明一个*已结束*会话的运行位置:`report.complete` 会继续携带 `host`。
669
578
 
670
- ## 7. 配置参考
579
+ ## 6. 配置参考
671
580
 
672
581
  | 环境变量 | 是否必需 | 效果 |
673
582
  | --- | --- | --- |
674
583
  | `ONLYNE_ROLE` | 是 | 挂载角色 |
675
584
  | `ONLYNE_SESSION_ID` | 是 | 挂载的会话 id;已发布客户端中的 `session_id` 等于 `task_id` |
676
585
  | `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) |
586
+ | `ONLYNE_SOCKET` | 否 | 客户端为此工作区提供服务的套接字,会注入所启动的每个会话进程;变量未设置时,插件自己去运行目录读注册文件(`<digest>.json`),挑出 `root` 就是本工作区的那个 client |
680
587
  | `ORCA_PANE_KEY` | 否 | 此进程的运行位置(`<tab_id>:<leaf_id>`),每次心跳通过 `observed.host.orca.pane_key` 上报;在 Orca 窗格之外未设置,因此该字段会缺失 |
681
588
  | `ORCA_TAB_ID` / `ORCA_LEAF_ID` | 否 | 单独的窗格 id;仅设置窗格键本身时,会对该键进行解析 |
682
589
  | `ORCA_TERMINAL_HANDLE` | 否 | 终端句柄,在窗格键旁以 `host.orca.handle` 上报,其值即 `orca terminal switch` 所使用的值 |
683
590
 
684
591
  需要知道的常量:插件每 10 s 发送一次心跳(`heartbeat_timeout_ms` 为 30 s),为 `hello` 留出 5 s,每个请求留出 30 s,并按 1/2/4/8/16/30 s 的阶梯重新连接。
685
592
 
686
- 插件会读取自己的三个文件:`<cwd>/.pi/onlyne.json`(开关,§1)、`package.json` 旁边的 `relay.toml`(中继策略的回退来源,仅在客户端没有注入策略时读取,§5),以及 `<cwd>/.onlyne/run/socket`(标记客户端守护进程所绑定的套接字路径,当环境变量未携带该路径时读取,§8)。
593
+ 插件会读取自己的一个文件:`<cwd>/.pi/onlyne.json`(开关,§1)。另一处读取属于机器而不是工作区:`ONLYNE_SOCKET` 未设置时,插件遍历机器级运行目录里的 client 注册文件,找出写下本工作区的那个(§7)。
687
594
 
688
- ## 8. 故障排除
595
+ ## 7. 故障排除
689
596
 
690
597
  | 症状 | 原因 | 检查 |
691
598
  | --- | --- | --- |
692
599
  | `[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` 包含同一路径,环境变量未注入任何值时,插件会连接该路径 |
600
+ | `socket unresolved: onlyne: no client is registered for <workspace> …` | 该工作区没有 `onlyne-client run`,所以运行目录里没有哪个注册文件的 `root` 是这棵树 | 起 client,或 `onlyne-client status`;这条消息会点出运行目录,以及它实际读到的每个注册文件 |
601
+ | `socket unresolved: … N clients there name runtime pi … ambiguous` | 有多个已注册的 client 都在跑 pi 会话,而它们的 root 都不包含本工作区,于是没有唯一可拨的 client | 用 `ONLYNE_SOCKET` 显式指定 socket,或为本工作区起 client |
602
+ | `socket error: connect ENOENT <路径>` | 消息里的路径没人 bind:注入它的那个 client 已经停了 | `onlyne-client status` 看它当前服务的 socket,再看 client 日志里带 `socket = <路径>` 的那行 |
695
603
  | `reconnecting in 4000ms` 持续循环 | 客户端已停止,或套接字已被替换 | `onlyne --server-root … roles` |
696
604
  | `ready refused: internal: unknown session for …` | 插件完成挂载并为一个客户端从未暂存的任务进行了报告(在任务之外手动启动 pi 时属于正常情况) | 在客户端下启动 pi,不手动启动 |
697
605
  | `assign` 始终未到达 | 客户端的 `session_command` 未启动 pi,或 `inject` 已被移除 | 在客户端日志中查看启动行;通过 `/onlyne status` 查看能力集合 |
698
- | 账本保持 `in_flight` | 尚未完成:还没有轮次运行(注入消息尚未执行),或者阶梯仍在提醒(`idleReminders`) | 在 pi 会话文件中查看 `onlyne-assign` 条目及其后注入的提醒;通过 `onlyne` 面板查看 `reminder n of m`;通过 `/onlyne status` 查看任务和阶段 |
699
- | `onlyne_complete` 回复 `relay guard: missing handoff to: …` | 工作区的规范(或替代该规范的 `relay.toml`)指定了一个此会话从未向其发送的角色 | 常规通知会出现在 `onlyne` 面板中;stderr 会保留拒绝消息,例如 `relay guard from …`、套接字错误、超时和分帧错误;`required=…` 指明策略;`relay guard: missing handoff …` 指明已送达的集合 |
606
+ | 账本保持 `in_flight` | 尚未完成:还没有轮次运行(注入消息尚未执行),或者该轮次结束时没有完成,而客户端尚未结算这次投递。在 pi-onlyne 1.2.1 对上 pi 0.87 时,带图片附件的信封会什么都没注入:pi 读取本插件现在送出的扁平 `ImageContent`,拒掉旧插件构造的嵌套 part,任务正文随之一起丢失 | 在 pi 会话文件中查看 `onlyne-assign` 条目及其后注入的 `nudge` 句子;通过 `/onlyne status` 查看任务和阶段;要找那次拒绝,看窗格里的 `Extension "<runtime>" error` 行,以及 1.2.2 上插件的 `attachment carried no base64 data or no media type` 日志 |
700
607
  | `hello … forbidden`/在 `hello` 后立即关闭连接 | 挂载角色与客户端角色不匹配 | `hello.args.mount.role` 与工作区角色 |
701
608
  | `frame_too_large` | 正文超过 8 MiB | 仅可通过超大的出站图像达到;此上限来自核心 |
702
609
  | 工具缺失 | 该 pi 版本中不存在 `pi.registerTool` | `/onlyne status`;上方的能力表 |
703
610
  | 后台任务仍在运行时,会话显示 `idle` | 未安装后台任务扩展,或其 EventBus 服务未在时限内回应状态查询,插件看不到自己留下的运行中工作 | `[pi-onlyne]` 日志中关于后台探针的行;同一 pi 会话中的 `bg_status` 会列出存活任务 |
704
611
  | 监管器面板未列出任何标签页 | 没有活动会话报告窗格:适配器早于该报告功能,或此 pi 不在 Orca 窗格内 | `onlyne --server-root … sessions --json` 中的 `projection.observed.host.orca.pane_key`;在窗格内执行 `env \| grep ORCA_` |
705
612
 
706
- `/onlyne status` 会打印实时状态(`connected`、`socket`、`role`、`sessionId`、`generation`、`agentState`、`tasks`、`pendingCompletion`、`lastError`、计数器),`/onlyne connect` / `/onlyne disconnect` 可手动打开和关闭套接字。
613
+ `/onlyne status` 会打印实时状态(`connected`、`socket`、`role`、`sessionId`、`generation`、`agentState`、`seq`、`taskSeqs`、`tasks`、`pendingCompletions`、`lastError`、计数器),`/onlyne connect` / `/onlyne disconnect` 可手动打开和关闭套接字。
707
614
 
708
- ## 9. 开发
615
+ ## 8. 开发
709
616
 
710
617
  ```bash
711
618
  cd plugins/onlyne-agent-pi
712
- node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard, socket path
619
+ node --test src/*.test.mjs # framing, protocol, agent state machine, config, socket path
713
620
  ```
714
621
 
715
622
  除非 `target/debug/onlyne-client` 和 `onlyne-server` 存在,否则 `src/agent.live.test.mjs` 会自行跳过。`crates/onlyne-testkit/e2e/pi-live.sh` 是端到端用例:pi 缺失或没有可用的模型凭据时,它会跳过(退出码 0);否则,它会通过真实客户端运行一个真实任务,直到 `acked`。