pi-onlyne 0.8.1 → 1.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,245 +1,367 @@
1
- # pi-onlyne
1
+ # pi-onlyne — the onlyne agent adapter for pi
2
2
 
3
- `pi-onlyne` gives [Pi](https://github.com/badlogic/pi-mono) agents a local IM inbox and outbox through [Onlyne](https://github.com/dbydd/onlyne). The extension connects Pi to an Onlyne workspace, exposes message tools, and delivers subscribed events as Pi follow-ups.
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
5
+ `crates/onlyne-adapter/PROTOCOL.md`, and drives a session through
6
+ `hello → welcome → assign → work → complete → detach`. No Rust code runs here: the
7
+ protocol is reimplemented on Node's `node:net`, with a hand-written four-byte
8
+ length-prefixed JSON codec, and the runtime has no npm dependencies.
4
9
 
5
- ## Runtime requirements
10
+ Outside an onlyne session the extension is inert. The client injects `ONLYNE_ROLE`,
11
+ `ONLYNE_SESSION_ID` and `ONLYNE_TASK_ID` into every process it spawns
12
+ (`crates/onlyne-client/src/dispatch.rs`). With any of the three missing, this is an
13
+ ordinary pi session: the plugin registers nothing and opens nothing.
6
14
 
7
- - Node.js 20 or newer
8
- - Pi 0.84 or newer with the `pi` command available in `PATH`
9
- - `onlyne` 0.4.x installed with `cargo install onlyne`, or a compatible local build
10
- - An initialized Onlyne workspace with `.onlyne/config.toml`
11
- - A configured model/provider for Pi agent replies
12
- - Unix domain socket support on the host
13
-
14
- The extension supports macOS and Linux. Each workspace keeps daemon state, channel credentials, history, sockets, and logs under its own `.onlyne/` directory.
15
-
16
- ## Install
17
-
18
- Install the published Pi package:
19
-
20
- ```bash
21
- pi install npm:pi-onlyne
22
- ```
23
-
24
- Run it for one Pi process:
25
-
26
- ```bash
27
- pi -e npm:pi-onlyne
28
15
  ```
29
-
30
- Install the package from a local checkout during development:
31
-
32
- ```bash
33
- cd path/to/pi-onlyne
34
- npm install
35
- npm run check
36
- pi install .
16
+ pi session (spawned by onlyne-client)
17
+ │ env: ONLYNE_ROLE / ONLYNE_SESSION_ID / ONLYNE_TASK_ID
18
+ │ .pi/onlyne.json: { "enabled": true, "watch": { "autoStart": true } }
19
+ ▼
20
+ hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{role,session,task_id,pid}}
21
+ ◀── welcome{role, prose, generation, server, host_capabilities}
22
+ ├─ prose ──► pi context, once (custom message, no turn)
23
+ ├─ 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_ack{accepted:true}
27
+ ├─ report.heartbeat{running|idle} — per turn, and every 10s while a task is live
28
+ ├─ report.complete{outcome, head} — the ledger's terminal fact
29
+ │ └─ then one report.heartbeat{agent:"idle"} carrying the settled tuple
30
+ │ └─ the client's answer is the handover: pi is asked to shut down, then detaches
31
+ ├─ probe ──► one heartbeat
32
+ ◀── recycle ──► complete (if unsettled) → stop → pi exits
33
+ └─ detach{reason} when pi shuts down
37
34
  ```
38
35
 
39
- The package publishes `dist/`, `README.md`, `SPEC.md`, and `LICENSE`. `prepublishOnly` runs the build and test suite.
40
-
41
- ## Prepare an Onlyne workspace
36
+ ## 1. Install
42
37
 
43
- Run these commands from the project that should receive the messages:
38
+ The plugin is a pi package: `package.json` declares `pi.extensions: ["./src/index.ts"]`,
39
+ so pi loads the TypeScript source directly (no build step).
44
40
 
45
- ```bash
46
- cargo install onlyne
47
- onlyne init
48
- onlyne export-skill
49
- ```
41
+ ### With a generated workspace (the normal path)
50
42
 
51
- Configure a channel in `.onlyne/config.toml` and place secrets in `.onlyne/.env`. Examples:
43
+ `onlyne server generate` copies `[server].agent_package` into
44
+ `<ws>/.onlyne/agent/<pkg-name>/`, and writes that package into `.pi/settings.json` as a
45
+ path relative to the settings file itself: `../.onlyne/agent/<pkg-name>`
46
+ (`crates/onlyne-server/src/generate.rs`). pi 0.85.1 loads only that spelling. A project
47
+ `packages` path resolves against the directory holding the settings file (`<ws>/.pi`), so
48
+ the `../` form reaches `<ws>/.onlyne/agent/<pkg-name>`, while a bare
49
+ `.onlyne/agent/<pkg-name>` entry would resolve to `<ws>/.pi/.onlyne/agent/<pkg-name>` and
50
+ list the package without loading it. A supervisor starts the generated workspace, and the
51
+ extension travels with it: nothing is installed globally.
52
52
 
53
53
  ```toml
54
- [adapters.telegram]
55
- enabled = true
56
-
57
- [adapters.feishu]
58
- enabled = true
59
-
60
- [adapters.qqbot]
61
- enabled = true
62
-
63
- [adapters.wechat]
64
- enabled = true
54
+ # spec.toml
55
+ [server]
56
+ agent_package = "/abs/path/to/integrations/pi-onlyne" # read once, at generate time
65
57
  ```
66
58
 
67
- Use the matching `onlyne auth` command for Feishu, QQ Bot, or WeChat. Telegram uses `TELEGRAM_BOT_TOKEN` in `.onlyne/.env`. Bind a target conversation with `bind_conversation_id`, or send `/handshake` from the desired conversation after the adapter starts.
68
-
69
- Start the daemon from the project root:
70
-
71
59
  ```bash
72
- onlyne run
73
- ```
74
-
75
- A Pi session can start or connect to the daemon through `/onlyne daemon start`.
76
-
77
- ## Configure Pi behavior
78
-
79
- The extension reads `.pi/onlyne.json` from the current Pi project. The default configuration is:
80
-
81
- ```json
82
- {
83
- "watch": { "autoStart": false },
84
- "inbound": { "defaultMode": "auto-handle", "rules": [] },
85
- "outbound": {
86
- "defaultReplyMode": "guarded-explicit",
87
- "guardedExplicit": {
88
- "reminders": 2,
89
- "noOutputFallbackText": "Onlyne/Pi error: no valid reply was produced."
90
- },
91
- "retry": { "attempts": 2, "concurrency": 8 }
92
- }
93
- }
60
+ onlyne server generate --root <server-root> --out <dir>
94
61
  ```
95
62
 
96
- Enable automatic subscription when Pi starts:
63
+ The generated `.pi/settings.json` then carries:
97
64
 
98
65
  ```json
99
- {
100
- "watch": { "autoStart": true }
101
- }
102
- ```
103
-
104
- The extension merges partial JSON with the defaults. `inbound.rules` accepts channel and optional conversation selectors with `auto-handle`, `queue-only`, or `muted` modes. `outbound.defaultReplyMode` accepts `guarded-explicit`, `explicit-only`, or `implicit-final`.
105
-
106
- ## Commands
107
-
108
- ```text
109
- /onlyne status
110
- /onlyne daemon start
111
- /onlyne daemon stop
112
- /onlyne daemon restart
113
- /onlyne watch on
114
- /onlyne watch off
115
- /onlyne config auto-start
116
- /onlyne swarm on
117
- /onlyne swarm off
118
- /onlyne swarm status
66
+ { "packages": ["../.onlyne/agent/pi-onlyne"] }
119
67
  ```
120
68
 
121
- `watch on` subscribes to the current workspace event stream. Incoming channel messages become Pi follow-ups. A normal inbound message receives `onlyne_reply`, and an intentional omission receives `onlyne_mark_no_reply`.
69
+ `pi list` shows the entry under "Project packages". To verify the load itself, make the
70
+ copied `index.ts` throw and watch for the failure.
122
71
 
123
- ## Agent tools
72
+ ### Manual (no generator)
124
73
 
125
- Normal mode:
126
-
127
- ```text
128
- onlyne_daemon_start()
129
- onlyne_daemon_stop()
130
- onlyne_daemon_restart()
131
- onlyne_reply({ text })
132
- onlyne_send({ channelId, text, rawText? })
133
- onlyne_broadcast({ targets, text, rawText? })
134
- onlyne_loopback({ text, rawText? })
135
- onlyne_mark_no_reply({ reason? })
136
- ```
137
-
138
- Swarm mode (`[swarm] enabled`):
139
-
140
- ```text
141
- onlyne_daemon_start()
142
- onlyne_daemon_stop()
143
- onlyne_daemon_restart()
144
- swarm_complete({ text })
145
- swarm_quit({ reason? })
146
- swarm_send({ to, text })
147
- swarm_status()
148
- ```
149
-
150
- One session sees one toolset, chosen at session start. Generic send/reply
151
- tools stay out of the swarm surface so unheaded writes cannot pollute the
152
- protocol.
153
-
154
- Reclaim uses a control wire on the same loopback path: the scheduler writes a
155
- header-only `---swarm-ctl` message (`op: recycle`), pi-onlyne intercepts it
156
- before delivery, acks `swarm_recycled { task_id, reason }`, stops watching,
157
- clears the slot, and exits its own process. The scheduler then closes the
158
- Orca tab. Missing acks are logged and the tab still closes.
159
-
160
- Messages use Markdown by default. `rawText: true` preserves literal text for scripts and protocol payloads.
161
-
162
- ### Send one message
163
-
164
- ```ts
165
- onlyne_send({
166
- channelId: "telegram",
167
- text: "# Build report\n\nAll checks passed."
168
- })
169
- ```
170
-
171
- ### Broadcast
172
-
173
- ```ts
174
- onlyne_broadcast({
175
- targets: [{ channelId: "telegram" }, { channelId: "feishu" }],
176
- text: "# Release shipped\n\nVersion 0.6.0 is live."
177
- })
74
+ ```bash
75
+ cp -R integrations/pi-onlyne <ws>/.onlyne/agent/pi-onlyne
76
+ printf '{"packages":["../.onlyne/agent/pi-onlyne"]}\n' > <ws>/.pi/settings.json
178
77
  ```
179
78
 
180
- ### Loopback wake-up
181
-
182
- A local script can wake the current Pi session through the daemon socket:
79
+ ### One-off / testing
183
80
 
184
81
  ```bash
185
- onlyne client '{"id":"wake","op":"loopback","text":"background job finished","raw_text":true}'
82
+ pi --session-id <id> -e /abs/path/to/integrations/pi-onlyne -ns -nc
186
83
  ```
187
84
 
188
- The extension also supports `.onlyne/channels/loopback/in` when FIFO IO is enabled.
189
-
190
- ## Swarm mode
191
-
192
- Swarm mode lets `onlyne-swarm >= 0.5.0` own task routing for a generated agent workspace. `pi-onlyne 0.8.1` uses the scheduler's `delivery: scheduler` header for task claims, so these versions upgrade together. Enable it in `.onlyne/config.toml`:
85
+ ### The switch file
86
+
87
+ `<cwd>/.pi/onlyne.json` (see `onlyne.json.example`):
88
+
89
+ | key | default | effect |
90
+ | --- | --- | --- |
91
+ | `enabled` | `true` | `false` turns the extension off for this workspace |
92
+ | `watch.autoStart` | `true` | `false` registers the tools but opens no socket until `/onlyne connect` |
93
+
94
+ A missing file means both defaults. A malformed file prints one warning on stderr and
95
+ keeps both defaults: a typo must not silently disable a role. The client does not read
96
+ this file (§11 of the plan downgraded the old readiness gates to generate-time template
97
+ advice), so only this extension consumes it; the key shape stays the one the templates
98
+ carry.
99
+
100
+ Nothing else is needed. The workspace's `session_command` in `spec.toml` already spawns
101
+ `pi` per task (`["pi", "--session-id", "{session}"]`), and the client injects the
102
+ environment this extension keys on.
103
+
104
+ ## 2. Capabilities
105
+
106
+ The `hello` frame declares what this plugin actually implements:
107
+
108
+ | capability | declared | what it means here |
109
+ | --- | --- | --- |
110
+ | `register` | always | `session_register{session_id, task_id, generation, pid, title}` after `welcome` |
111
+ | `report` | always | `report.ready` / `report.heartbeat` / `report.complete` |
112
+ | `inject` | when `pi.sendUserMessage` exists | the payload arrives as `assign` and is injected as a pi user message |
113
+ | `recycle` | always | `recycle` settles the task if it is unsettled, then stops the plugin and exits pi |
114
+
115
+ What happens when a pi API is missing, and what the host does then:
116
+
117
+ | gap | detection | behaviour |
118
+ | --- | --- | --- |
119
+ | no `registerTool` (older pi) | probed at `session_start` | no tools are registered; the protocol path is unaffected, and `/onlyne status` still works |
120
+ | 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 |
121
+ | no `sendMessage` | probed | the role prose from `welcome` is not injected as context; the task itself still arrives |
122
+ | no `appendEntry` | probed | no `onlyne-assign` / `onlyne-complete` session entries are recorded |
123
+ | no `ui.setStatus` | guarded | the footer status line is skipped |
124
+ | no `ctx.shutdown` | guarded | `recycle` and a completion still settle the task; the process stays up for the operator to close |
125
+
126
+ ## 3. Tools
127
+
128
+ Registered only inside an onlyne session.
129
+
130
+ ### `onlyne_send{to, text, kind?, image?}`
131
+
132
+ Sends one envelope on the `send` frame. `kind: "note"` (the default) is free text and
133
+ carries no `op_id`. `kind: "task"` hands work to a role, so it carries an `o-<uuid>`
134
+ idempotency key and a fresh `causality.task`. `image` is an absolute path to a
135
+ png/jpeg/gif/webp file: the plugin reads it, base64-encodes it and attaches it as
136
+ `body.image`. The core caps that at 2 MiB and accepts four mime types.
137
+
138
+ ### `onlyne_complete{outcome?, text?, force?, reason?}`
139
+
140
+ Ends the current task with an explicit outcome (`done` by default, or `failed`). A
141
+ non-empty `text` becomes the ledger `head` verbatim: whitespace collapses to one line and
142
+ the text stops at 200 characters. An absent or blank `text` carries no summary, so the
143
+ completion falls back to the last assistant text. The call also ends the session's
144
+ process: once the client has acknowledged the completion report (see §4), the plugin asks
145
+ pi to shut down through `ctx.shutdown()`. pi 0.85.1 has no tool-result `terminate`
146
+ handling. When the workspace carries a relay policy (§5), `force: true` with a non-empty
147
+ `reason` is the deliberate way past a handoff the session still owes.
148
+
149
+ ## 4. Outcome rules
150
+
151
+ The plugin sends one completion per task, at the first of these events:
152
+
153
+ 1. **`onlyne_complete`** — the model gives an explicit outcome. It wins over everything
154
+ else, and a later completion for the same task is refused (not re-reported). Its
155
+ non-empty `text` is the head.
156
+ 2. **`agent_settled`** — pi will not continue on its own: no retry, compaction or queued
157
+ continuation is pending. The plugin reports:
158
+ - `failed` when the turn ended with a provider error (`stopReason: "error"`), with the
159
+ error as the head;
160
+ - `done` otherwise, with the last assistant text as the head;
161
+ - nothing at all when the task was assigned but no turn has run yet. The injected
162
+ message has not executed, so completing now would claim work that never happened.
163
+ 3. **`recycle{outcome}`** — the host is tearing the session down. The plugin settles an
164
+ unsettled task with the host's outcome first, then stops and exits pi.
165
+
166
+ `head` is a single line, capped at 200 characters; it matches what the client puts in
167
+ `out_head` and what the receipt carries. Each task has one source for it: the `text` of
168
+ the explicit `onlyne_complete` call when that call carried one, and the last assistant
169
+ text otherwise. The auto rule is that fallback path: it reports the text of the turn it
170
+ settles, and a sentence spoken after the call cannot replace what the call handed over.
171
+
172
+ A reported completion ends the session's process. `report.complete` goes out as a request,
173
+ and the client answers it only after it has settled the session row, acked the delivery
174
+ and written the `Completion` envelope. The plugin asks pi to shut down at that answer. An
175
+ outcome the socket could not carry is queued and flushed after the next `hello`, and that
176
+ flush's answer is the handover that ends the process. A completion the host refused leaves
177
+ the process running, so an exit never loses the task.
178
+
179
+ The last report is one observation with `agent: "idle"` beside the settled outcome, sent
180
+ after the completion is acknowledged and before the process leaves. The completion settles
181
+ the row from the tuple the client holds, and that tuple still reads `running` when the
182
+ finishing turn was the last heartbeat. Nothing observes the process afterwards, so without
183
+ this report an exited session keeps saying `running`. The plugin skips it when the last
184
+ beat was already idle, and a refused settled observation does not hold up the exit the
185
+ completion earned.
186
+
187
+ ## 5. Relay guard
188
+
189
+ A session can hand no work over and still report `done`. That is the accident the guard
190
+ closes: a bench session narrated its progress, called `onlyne_complete` with its todos
191
+ untouched, and the downstream writer waited for a handoff that was never sent. The guard
192
+ judges delivery facts only — whether a role was reached — and never the shape or quality
193
+ of the text that was sent.
194
+
195
+ The policy lives next to the plugin's `package.json`, so it travels inside the copy a
196
+ generated workspace loads: `<ws>/.onlyne/agent/pi-onlyne/relay.toml` in a generated
197
+ workspace, `relay.toml` in a manual installation.
193
198
 
194
199
  ```toml
195
- [swarm]
196
- enabled = true
200
+ relay_required = ["writer"] # these roles must have received a handoff
201
+ relay_required_count = 2 # ... or this many distinct downstream roles
197
202
  ```
198
203
 
199
- A swarm Pi session subscribes to loopback events, reports `swarm_ready`, accepts one scheduler-delivered hop atomically, spawns continuations with `swarm_send` (fire-and-forget), and writes `swarm_complete` (done signal). A raw `swarm_send` relay carries no `delivery` header and only creates the scheduler task row; the scheduler's second delivery adds `delivery: scheduler`, which pi-onlyne uses as the claim gate. This keeps arbitrary role prose out of transport authentication. Scheduler then sends a header-only recycle control wire; pi-onlyne intercepts it before model delivery, sends `swarm_recycled`, stops the watch, clears the slot, and self-exits. `swarm_quit` sends the same ack with `quit:<reason>` then self-exits, so the scheduler records failed immediately. Downstream results travel through files and the ledger; nothing waits.
204
+ `relay_required` wins when both keys are present.
200
205
 
201
- For automatic startup in a generated workspace, add `.pi/onlyne.json`:
206
+ The policy belongs in the spec, not in the vendor directory. `onlyne generate --force`
207
+ rewrites the copy this package is vendored into and takes a hand-written `relay.toml`
208
+ with it, so a `[[client]]` entry states the policy once and the client injects it into
209
+ every session process it spawns:
202
210
 
203
- ```json
204
- {
205
- "watch": { "autoStart": true },
206
- "outbound": {
207
- "defaultReplyMode": "explicit-only",
208
- "retry": { "attempts": 4, "concurrency": 8 }
209
- }
210
- }
211
+ ```toml
212
+ [[client]]
213
+ role = "planner"
214
+ relay_required = ["writer"] # these roles must have received a handoff
215
+ relay_count = 2 # ... or this many distinct downstream roles
211
216
  ```
212
217
 
213
- The swarm scheduler starts Pi with normal extension discovery. The configured retry extension remains available in swarm sessions. See the [onlyne-swarm README](https://github.com/dbydd/onlyne-swarm) for the graph template, scheduler commands, Orca requirements, and test runner.
214
-
215
- ## Local state and security
216
-
217
- Pi-side settings live at `.pi/onlyne.json`. Onlyne stores credentials, history, sockets, logs, and adapter state under `.onlyne/`. Keep `.onlyne/.env` private. Review package source before installing third-party extensions because Pi extensions run with the permissions of the Pi process.
218
-
219
- ## Release notes
220
-
221
- The checkout in this repository tracks the published version in `package.json`.
222
- Run `npm run check` before any release so the build and tests regenerate
223
- `dist/`. Publish with `npm publish` after reviewing the generated tarball.
224
-
225
- ## Development
218
+ The sources rank `environment > relay.toml > none`: `ONLYNE_RELAY_REQUIRED` (the list,
219
+ comma-separated) and `ONLYNE_RELAY_COUNT` (the count, decimal) are the variables the
220
+ client fills from the entry above; a `relay.toml` beside `package.json` is read only
221
+ when the environment names no policy at all; and neither one means no guard. Both
222
+ variables are injected when the spec names both, so the list still wins. A hand-written
223
+ `relay.toml` remains the manual installation's escape hatch — for a box whose spec
224
+ never states the policy — and a file shadowed by the environment is ignored outright. A
225
+ variable that is set but unparsable is reported on stderr and ignored, which gives the
226
+ file its turn.
227
+
228
+ | | |
229
+ | --- | --- |
230
+ | default | neither source names a policy: no guard, and the completion path is the one this plugin shipped before the guard existed |
231
+ | evidence | the roles this session's own successful `onlyne_send` calls reached, `note` and `task` alike; a refused envelope counts for nothing |
232
+ | refusal | `onlyne_complete` throws `onlyne: relay guard: missing handoff to: writer (…)`, naming what is missing and how to clear it |
233
+ | after a refusal | nothing is reported, queued or detached: the session stays mounted, and the same call lands once the handoff has gone out |
234
+ | list mode | every named role must be in the delivered set, literally |
235
+ | count mode | distinct downstream roles; a send to this role itself or back to the role that assigned the task is not one |
236
+ | 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 |
237
+ | waiver | `force: true` with a non-empty `reason`; it only matters when the guard refuses |
238
+ | audit | a waived completion's ledger head starts with `relay-guard-forced: <reason>`, followed by the model's `text` when the call carried one |
239
+ | not guarded | the automatic outcomes: `agent_settled` and `recycle{outcome}` still complete a task that owes a handoff |
240
+
241
+ `relay.toml` is a closed subset of TOML: flat `key = value` lines, the two keys above,
242
+ one-line arrays of double-quoted strings, `#` comments. Anything outside that warns on
243
+ stderr and is ignored. It is deliberately not `.onlyne/config.toml`: the client parses
244
+ that file with `deny_unknown_fields`, so a plugin key there would stop the client from
245
+ starting at all.
246
+
247
+ `force` and `reason` are inert when no policy is in force.
248
+
249
+ ## 6. Protocol notes and deviations
250
+
251
+ Each item below is either a deliberate reading of `PROTOCOL.md` or a behaviour measured on
252
+ the shipped client.
253
+
254
+ - **Report sequence base.** The plugin's own `report` sequence starts at 1000, not 1. The
255
+ client stamps its own dispatch events (`created`, resource attach, `ready`) into the
256
+ same `(generation, seq)` watermark, and the reducer silently drops any report at or
257
+ below it (`crates/onlyne-session/src/reconcile.rs`). A plugin sequence starting at 1
258
+ would lose its first observations. Everything else about the versioning is per spec.
259
+ - **`observed` is a full `Observation`.** `report.heartbeat` carries the whole legal state
260
+ tuple (`version`, `generation_live`, `isolate_after`, `terminate_after`,
261
+ `mismatch_count`, `agent`, `delivery`, `resource`, `recovery`, `outcome`, `public`), not
262
+ a `{"state": "running"}` shorthand: the host deserialises it and rejects anything
263
+ `is_legal` refuses. This plugin owns only the `agent` dimension (turn hooks). It leaves
264
+ `delivery` at `none` and `outcome` at `pending`, which is its own truth until it reports
265
+ a completion. It reports `resource` as `attached` because the host's own dispatch path
266
+ already recorded the attach.
267
+ - **`ready` is reported once per connection.** The host's own hand-off path
268
+ (`crates/onlyne-client/src/dispatch.rs::hand_session`) already reports `ready` when the
269
+ client stages the session for a mounting plugin, so a second report from the plugin is a
270
+ no-op at the host. The plugin sends it anyway: a plugin that mounts *before* any work
271
+ exists is the case the ready barrier names, and it costs one frame.
272
+ - **`cluster_ref` is never sent.** This plugin speaks for a local role, never for an
273
+ aggregate; the field is `skip_serializing_if` absent on the Rust side for the same
274
+ reason.
275
+ - **`probe` is answered with a heartbeat**, per `PROTOCOL.md`'s "a `probe` declares fresh
276
+ resource observations".
277
+ - **`config_get` is read as a task body only when it starts with `stdin:`**, which is the
278
+ overload `PROTOCOL.md` documents for plugins without `inject`. Any other key is logged
279
+ and ignored, never misread.
280
+ - **`frame_too_large` / `bad_frame`**: an oversize body is refused before any byte is
281
+ written, and a framing fault closes the connection and reconnects. Framing cannot
282
+ resynchronise after a corrupt body, which is the same conclusion
283
+ `crates/onlyne-frame/src/lib.rs` reaches.
284
+ - **Task ids here are single-use.** The plugin acks duplicate `assign` deliveries for the
285
+ same task (`reason: "duplicate"`) without a second injection, and remembers the id for
286
+ the life of the connection. The client today mints a fresh uuid per task, so this only
287
+ ever fires on a genuine redelivery.
288
+
289
+ - **Pane binding (Orca tabs).** Inside an Orca pane the plugin reports the pane it runs in on every
290
+ heartbeat, as `observed.host.orca.pane_key` in the report's `Observation`
291
+ (`crates/onlyne-session/src/host.rs`), beside `tab_id` / `leaf_id` and the terminal `handle` when
292
+ the environment names them. The binding is *inherited*, never guessed: an Orca pane exports
293
+ `ORCA_PANE_KEY` / `ORCA_TAB_ID` / `ORCA_LEAF_ID` / `ORCA_TERMINAL_HANDLE` into the command it
294
+ starts (measured 2026-09-11, Orca 1.4.198), and the client passes its own environment on to the
295
+ session command. So the process inside a pane is the only component that can state, from the
296
+ inside, which pane an onlyne session is; nothing downstream of pi can recover that. Outside a
297
+ pane the `host` key is absent altogether: a pi on a plain terminal reports an observation with no
298
+ host field, rather than one with an empty pane.
299
+ - **Nothing is written to the workspace for this.** There is no claim file any more: the binding
300
+ rides the observation the client already mirrors. A stale one cannot exist, because nothing
301
+ creates one, and the workspace's cache directory is not touched. That is what lets
302
+ `integrations/orca-plugin` scope its tab axis to real sessions without reading any path, and what
303
+ lets a supervisor still say where a *finished* session ran: `report.complete` carries `host`
304
+ forward.
305
+
306
+ ## 7. Configuration reference
307
+
308
+ | env var | required | effect |
309
+ | --- | --- | --- |
310
+ | `ONLYNE_ROLE` | yes | the mount role |
311
+ | `ONLYNE_SESSION_ID` | yes | mounted session id; `session_id` equals `task_id` in the shipped client |
312
+ | `ONLYNE_TASK_ID` | yes | the task this process serves; drives `session_register` and the initial `ready` |
313
+ | `ONLYNE_SOCKET` | no | overrides the socket path (default `<cwd>/.onlyne/run/s`) |
314
+ | `ONLYNE_RELAY_REQUIRED` | no | the role's spec `relay_required`, comma-joined: the guard's list mode (§5) |
315
+ | `ONLYNE_RELAY_COUNT` | no | the role's spec `relay_count`: the guard's count mode, which decides only when the list is empty (§5) |
316
+ | `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 |
317
+ | `ORCA_TAB_ID` / `ORCA_LEAF_ID` | no | the pane ids separately; the pane key is parsed when only the key itself is set |
318
+ | `ORCA_TERMINAL_HANDLE` | no | the terminal handle, reported beside the pane key as `host.orca.handle`, and the value `orca terminal switch` takes |
319
+
320
+ Constants worth knowing: the plugin heartbeats every 10 s (`heartbeat_timeout_ms` is 30 s),
321
+ allows 5 s for `hello` and 30 s per request, and reconnects on a 1/2/4/8/16/30 s ladder.
322
+
323
+ The plugin reads two files of its own: `<cwd>/.pi/onlyne.json` (the switch, §1) and
324
+ `relay.toml` next to its `package.json` (the relay policy's fallback, read only when the
325
+ client injected none, §5).
326
+
327
+ ## 8. Troubleshooting
328
+
329
+ | symptom | cause | check |
330
+ | --- | --- | --- |
331
+ | `[pi-onlyne] session …` never appears | one of the three env vars is missing, or `enabled` is false | `env \| grep ONLYNE_`; `cat .pi/onlyne.json` |
332
+ | `socket error: connect ENOENT …/.onlyne/run/s` | no `onlyne-client run` for this workspace | start the client, or `onlyne-client status` |
333
+ | `reconnecting in 4000ms` in a loop | the client is down or the socket was replaced | `onlyne --server-root … roles` |
334
+ | `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 |
335
+ | `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 |
336
+ | ledger stays `in_flight` | no completion was reported: no turn ran, or `agent_settled` never fired | the pi session file for `onlyne-assign` / `onlyne-complete` entries |
337
+ | `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 | the plugin's stderr line `relay guard from …` names the source and `required=…` the policy; `relay guard: missing handoff …` names the delivered set |
338
+ | `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 |
339
+ | `frame_too_large` | a body above 8 MiB | only reachable through an oversize outbound image; the ceiling is the core's |
340
+ | tools missing | `pi.registerTool` is absent in that pi version | `/onlyne status`; the capability table above |
341
+ | session reads `idle` again after `exited` | a heartbeat snapshot landed after the completion, carrying `outcome: pending` | the session log for the report order after `completion`; the plugin stops reporting for a completed task |
342
+ | 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 |
343
+
344
+ `/onlyne status` prints the live state (`connected`, `socket`, `role`, `sessionId`,
345
+ `generation`, `agentState`, `tasks`, `pendingCompletion`, `lastError`, counters), and
346
+ `/onlyne connect` / `/onlyne disconnect` open and close the socket by hand.
347
+
348
+ ## 9. Development
226
349
 
227
350
  ```bash
228
- npm install
229
- npm run check
230
- npm pack --dry-run
351
+ cd integrations/pi-onlyne
352
+ node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard
231
353
  ```
232
354
 
233
- `npm run check` compiles TypeScript and runs the Node test suite. The tests cover configuration, workspace discovery, daemon connection, swarm header parsing, and the atomic swarm task slot.
234
-
235
- ## Links
355
+ `src/agent.live.test.mjs` skips itself unless `target/debug/onlyne-client` and
356
+ `onlyne-server` exist. `crates/onlyne-testkit/e2e/pi-live.sh` is the end-to-end case: it
357
+ skips (exit 0) when pi is absent or has no working model credentials, and otherwise runs
358
+ one real task through a real client to `acked`.
236
359
 
237
- - Onlyne: https://github.com/dbydd/onlyne
238
- - Onlyne documentation: https://github.com/dbydd/onlyne/tree/dev/docs
239
- - npm package: https://www.npmjs.com/package/pi-onlyne
240
- - pi-onlyne source: https://github.com/dbydd/pi-onlyne
241
- - onlyne-swarm: https://github.com/dbydd/onlyne-swarm
242
-
243
- ## License
360
+ ```bash
361
+ cd ../..
362
+ ONLYNE_BACKEND=fake BIN_DIR=target/debug bash crates/onlyne-testkit/e2e/pi-live.sh
363
+ ```
244
364
 
245
- MIT
365
+ After sourcing the shared helpers, the case exports `ONLYNE_BACKEND=exec`, so the client
366
+ spawns pi itself with a stdin pipe it keeps open for the life of the session. The
367
+ agent's own output lands in `<ws>/.onlyne/logs/session-<task>.log`.