pi-onlyne 1.2.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -240
- package/README.zh.md +79 -125
- package/package.json +2 -3
- package/src/activity.test.mjs +0 -6
- package/src/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -395
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/config.test.mjs +0 -8
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +110 -30
- package/src/pi-surface.test.mjs +232 -0
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -69
- package/src/socket.mjs +270 -39
- package/src/socket.test.mjs +205 -47
- package/relay.toml.example +0 -18
- package/src/relay.mjs +0 -299
- package/src/relay.test.mjs +0 -210
package/README.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
|
-
|
|
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 ──►
|
|
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
|
-
├─
|
|
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
|
|
30
|
-
│
|
|
31
|
-
|
|
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
|
|
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 `
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
189
|
-
`
|
|
190
|
-
re-reported). Its non-empty `
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 `
|
|
214
|
-
the explicit `onlyne_complete` call when that call carried one, the error a failed
|
|
215
|
-
reported
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
252
|
-
|
|
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
|
-
|
|
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-
|
|
331
|
-
would lose its first observations.
|
|
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
|
|
355
|
-
overload `PROTOCOL.md` documents for plugins without `inject
|
|
356
|
-
and
|
|
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-
|
|
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
|
|
365
|
-
|
|
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-
|
|
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
|
-
##
|
|
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
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
##
|
|
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
|
|
413
|
-
| `socket
|
|
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
|
|
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`, `
|
|
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
|
-
##
|
|
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,
|
|
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
|
|
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 ──►
|
|
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
|
-
├─
|
|
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
|
-
├─
|
|
473
|
-
│
|
|
474
|
-
|
|
475
|
-
├─ report.complete{outcome, head} —
|
|
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` 存在时 |
|
|
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
|
|
560
|
-
|
|
|
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
|
-
|
|
529
|
+
结果为 `sent to <role>`。
|
|
530
|
+
|
|
531
|
+
### `onlyne_complete{outcome, summary, details?, files?}`
|
|
579
532
|
|
|
580
|
-
|
|
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
|
|
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
|
|
591
|
-
2. **出错的轮次**——该轮次以模型提供方错误结束(`stopReason: "error"`)。这本身即可证明出错,因此插件立即报告 `failed`,并将错误作为 head
|
|
592
|
-
3.
|
|
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`
|
|
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
|
-
##
|
|
563
|
+
## 5. 协议说明与差异
|
|
655
564
|
|
|
656
565
|
下面每项都是对 `PROTOCOL.md` 的有意解读,或是在已发布客户端上测得的行为。
|
|
657
566
|
|
|
658
|
-
- **报告序列基线。** 插件自身的 `report` 序列从 1000 开始,而非 1。客户端将自己的调度事件(`created`、资源附加、`ready`)记入同一个 `(generation, seq)` 水位,归约器会静默丢弃任何小于或等于该水位的报告(`crates/onlyne-
|
|
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:`
|
|
664
|
-
- **`frame_too_large` / `bad_frame`**:正文过大时,会在写入任何字节之前拒绝;分帧错误会关闭连接并重新连接。正文损坏后,分帧无法重新同步,这也与 `crates/onlyne-
|
|
665
|
-
- **投递具备幂等性;任务不具备。** 去重键是信封 id。同一投递出现两次只会产生一次注入,以及带有 `reason: "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-
|
|
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
|
-
##
|
|
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` | 否 |
|
|
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
|
-
|
|
593
|
+
插件会读取自己的一个文件:`<cwd>/.pi/onlyne.json`(开关,§1)。另一处读取属于机器而不是工作区:`ONLYNE_SOCKET` 未设置时,插件遍历机器级运行目录里的 client 注册文件,找出写下本工作区的那个(§7)。
|
|
687
594
|
|
|
688
|
-
##
|
|
595
|
+
## 7. 故障排除
|
|
689
596
|
|
|
690
597
|
| 症状 | 原因 | 检查 |
|
|
691
598
|
| --- | --- | --- |
|
|
692
599
|
| `[pi-onlyne] session …` 始终未出现 | 三个环境变量中缺少一个,或 `enabled` 为 false | `env \| grep ONLYNE_`;`cat .pi/onlyne.json` |
|
|
693
|
-
| `socket
|
|
694
|
-
|
|
|
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` |
|
|
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`、`
|
|
613
|
+
`/onlyne status` 会打印实时状态(`connected`、`socket`、`role`、`sessionId`、`generation`、`agentState`、`seq`、`taskSeqs`、`tasks`、`pendingCompletions`、`lastError`、计数器),`/onlyne connect` / `/onlyne disconnect` 可手动打开和关闭套接字。
|
|
707
614
|
|
|
708
|
-
##
|
|
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,
|
|
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`。
|