pi-onlyne 0.9.1 → 1.1.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,384 @@
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.
36
+ ## 1. Install
40
37
 
41
- ## Prepare an Onlyne workspace
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).
42
40
 
43
- Run these commands from the project that should receive the messages:
41
+ ### With a generated workspace (the normal path)
44
42
 
45
- ```bash
46
- cargo install onlyne
47
- onlyne init
48
- onlyne export-skill
49
- ```
50
-
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/plugins/onlyne-agent-pi" # 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
60
+ onlyne server generate --root <server-root> --out <dir>
73
61
  ```
74
62
 
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
- }
94
- ```
95
-
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
- }
66
+ { "packages": ["../.onlyne/agent/onlyne-agent-pi"] }
102
67
  ```
103
68
 
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
119
- ```
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.
120
71
 
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`.
72
+ ### Manual (no generator)
122
73
 
123
- ## Agent tools
124
-
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
- })
74
+ ```bash
75
+ cp -R plugins/onlyne-agent-pi <ws>/.onlyne/agent/onlyne-agent-pi
76
+ printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings.json
169
77
  ```
170
78
 
171
- ### Broadcast
79
+ ### From npm
172
80
 
173
- ```ts
174
- onlyne_broadcast({
175
- targets: [{ channelId: "telegram" }, { channelId: "feishu" }],
176
- text: "# Release shipped\n\nVersion 0.6.0 is live."
177
- })
81
+ ```bash
82
+ pi install pi-onlyne # user-level: every pi process on this box loads it
178
83
  ```
179
84
 
180
- ### Loopback wake-up
85
+ The published package is `pi-onlyne` on npm; `pi install pi-onlyne@<version>` pins
86
+ one. This route reaches ordinary interactive sessions too, and there the extension
87
+ stays inert (no `ONLYNE_ROLE`, so no adapter). A role workspace needs no global
88
+ install to get a panel: the file-level copy above, or `onlyne server generate`,
89
+ scopes the plugin to the workspace that serves the role.
181
90
 
182
- A local script can wake the current Pi session through the daemon socket:
91
+ ### One-off / testing
183
92
 
184
93
  ```bash
185
- onlyne client '{"id":"wake","op":"loopback","text":"background job finished","raw_text":true}'
94
+ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
186
95
  ```
187
96
 
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.9.1` uses the scheduler's `delivery: scheduler` header for task claims and binds a scheduler-created pane to its `ONLYNE_SWARM_TASK`, so these versions upgrade together. Enable it in `.onlyne/config.toml`:
97
+ ### The switch file
98
+
99
+ `<cwd>/.pi/onlyne.json` (see `onlyne.json.example`):
100
+
101
+ | key | default | effect |
102
+ | --- | --- | --- |
103
+ | `enabled` | `true` | `false` turns the extension off for this workspace |
104
+ | `watch.autoStart` | `true` | `false` registers the tools but opens no socket until `/onlyne connect` |
105
+
106
+ A missing file means both defaults. A malformed file prints one warning on stderr and
107
+ keeps both defaults: a typo must not silently disable a role. The client does not read
108
+ this file (§11 of the plan downgraded the old readiness gates to generate-time template
109
+ advice), so only this extension consumes it; the key shape stays the one the templates
110
+ carry.
111
+
112
+ Nothing else is needed. The workspace's `session_command` in `spec.toml` already spawns
113
+ `pi` per task (`["pi", "--session-id", "{session}"]`), and the client injects the
114
+ environment this extension keys on.
115
+
116
+ ## 2. Capabilities
117
+
118
+ The `hello` frame declares what this plugin actually implements:
119
+
120
+ | capability | declared | what it means here |
121
+ | --- | --- | --- |
122
+ | `register` | always | `session_register{session_id, task_id, generation, pid, title}` after `welcome` |
123
+ | `report` | always | `report.ready` / `report.heartbeat` / `report.complete` |
124
+ | `inject` | when `pi.sendUserMessage` exists | the payload arrives as `assign` and is injected as a pi user message |
125
+ | `recycle` | always | `recycle` settles the task if it is unsettled, then stops the plugin and exits pi |
126
+
127
+ What happens when a pi API is missing, and what the host does then:
128
+
129
+ | gap | detection | behaviour |
130
+ | --- | --- | --- |
131
+ | no `registerTool` (older pi) | probed at `session_start` | no tools are registered; the protocol path is unaffected, and `/onlyne status` still works |
132
+ | 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 |
133
+ | no `sendMessage` | probed | the role prose from `welcome` is not injected as context; the task itself still arrives |
134
+ | no `appendEntry` | probed | no `onlyne-assign` / `onlyne-complete` session entries are recorded |
135
+ | no `ui.setStatus` | guarded | the footer status line is skipped |
136
+ | no `ui.setWidget` | guarded | routine notices continue through the footer status line and the `[pi-onlyne]` stderr line |
137
+ | no `ctx.shutdown` | guarded | `recycle` and a completion still settle the task; the process stays up for the operator to close |
138
+
139
+ ### Activity panel
140
+
141
+ When the host reports a UI (`ctx.hasUI`, true in the TUI and RPC modes, false in print and JSON modes) and `ctx.ui.setWidget` is available, routine onlyne notices draw in the panel above the editor with widget key `onlyne`. The header shows role, connection state, generation, the current task id, and phase. Below it, up to six newest-first events use `<=` for inbound frames, `=>` for outbound frames, `!!` for warnings, `..` for state changes, and `~~` for duplicate deliveries. Repeated identical events fold into one line with `xN`; the panel holds at most eight lines, each capped at 96 cells, and `session_shutdown` clears it.
142
+
143
+ ## 3. Tools
144
+
145
+ Registered only inside an onlyne session.
146
+
147
+ ### `onlyne_send{to, text, kind?, image?}`
148
+
149
+ Sends one envelope on the `send` frame. `kind: "note"` (the default) is free text and
150
+ carries no `op_id`. `kind: "task"` hands work to a role, so it carries an `o-<uuid>`
151
+ idempotency key and a fresh `causality.task`. `image` is an absolute path to a
152
+ png/jpeg/gif/webp file: the plugin reads it, base64-encodes it and attaches it as
153
+ `body.image`. The core caps that at 2 MiB and accepts four mime types.
154
+
155
+ ### `onlyne_complete{outcome?, text?, force?, reason?}`
156
+
157
+ Ends the current task with an explicit outcome (`done` by default, or `failed`). A
158
+ non-empty `text` becomes the ledger `head` verbatim: whitespace collapses to one line and
159
+ the text stops at 200 characters. An absent or blank `text` carries no summary, so the
160
+ completion falls back to the last assistant text. The call also ends the session's
161
+ process: once the client has acknowledged the completion report (see §4), the plugin asks
162
+ pi to shut down through `ctx.shutdown()`. pi 0.85.1 has no tool-result `terminate`
163
+ handling. When the workspace carries a relay policy (§5), `force: true` with a non-empty
164
+ `reason` is the deliberate way past a handoff the session still owes.
165
+
166
+ ## 4. Outcome rules
167
+
168
+ The plugin sends one completion per task, at the first of these events:
169
+
170
+ 1. **`onlyne_complete`** — the model gives an explicit outcome. It wins over everything
171
+ else, and a later completion for the same task is refused (not re-reported). Its
172
+ non-empty `text` is the head.
173
+ 2. **`agent_settled`** — pi will not continue on its own: no retry, compaction or queued
174
+ continuation is pending. The plugin reports:
175
+ - `failed` when the turn ended with a provider error (`stopReason: "error"`), with the
176
+ error as the head;
177
+ - `done` otherwise, with the last assistant text as the head;
178
+ - nothing at all when the task was assigned but no turn has run yet. The injected
179
+ message has not executed, so completing now would claim work that never happened.
180
+ 3. **`recycle{outcome}`** — the host is tearing the session down. The plugin settles an
181
+ unsettled task with the host's outcome first, then stops and exits pi.
182
+
183
+ `head` is a single line, capped at 200 characters; it matches what the client puts in
184
+ `out_head` and what the receipt carries. Each task has one source for it: the `text` of
185
+ the explicit `onlyne_complete` call when that call carried one, and the last assistant
186
+ text otherwise. The auto rule is that fallback path: it reports the text of the turn it
187
+ settles, and a sentence spoken after the call cannot replace what the call handed over.
188
+
189
+ A reported completion ends the session's process. `report.complete` goes out as a request,
190
+ and the client answers it only after it has settled the session row, acked the delivery
191
+ and written the `Completion` envelope. The plugin asks pi to shut down at that answer. An
192
+ outcome the socket could not carry is queued and flushed after the next `hello`, and that
193
+ flush's answer is the handover that ends the process. A completion the host refused leaves
194
+ the process running, so an exit never loses the task.
195
+
196
+ The last report is one observation with `agent: "idle"` beside the settled outcome, sent
197
+ after the completion is acknowledged and before the process leaves. The completion settles
198
+ the row from the tuple the client holds, and that tuple still reads `running` when the
199
+ finishing turn was the last heartbeat. Nothing observes the process afterwards, so without
200
+ this report an exited session keeps saying `running`. The plugin skips it when the last
201
+ beat was already idle, and a refused settled observation does not hold up the exit the
202
+ completion earned.
203
+
204
+ ## 5. Relay guard
205
+
206
+ A session can hand no work over and still report `done`. That is the accident the guard
207
+ closes: a bench session narrated its progress, called `onlyne_complete` with its todos
208
+ untouched, and the downstream writer waited for a handoff that was never sent. The guard
209
+ judges delivery facts only — whether a role was reached — and never the shape or quality
210
+ of the text that was sent.
211
+
212
+ The policy lives next to the plugin's `package.json`, so it travels inside the copy a
213
+ generated workspace loads: `<ws>/.onlyne/agent/onlyne-agent-pi/relay.toml` in a generated
214
+ workspace, `relay.toml` in a manual installation.
193
215
 
194
216
  ```toml
195
- [swarm]
196
- enabled = true
217
+ relay_required = ["writer"] # these roles must have received a handoff
218
+ relay_required_count = 2 # ... or this many distinct downstream roles
197
219
  ```
198
220
 
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.
221
+ `relay_required` wins when both keys are present.
200
222
 
201
- For automatic startup in a generated workspace, add `.pi/onlyne.json`:
223
+ The policy belongs in the spec, not in the vendor directory. `onlyne generate --force`
224
+ rewrites the copy this package is vendored into and takes a hand-written `relay.toml`
225
+ with it, so a `[[client]]` entry states the policy once and the client injects it into
226
+ every session process it spawns:
202
227
 
203
- ```json
204
- {
205
- "watch": { "autoStart": true },
206
- "outbound": {
207
- "defaultReplyMode": "explicit-only",
208
- "retry": { "attempts": 4, "concurrency": 8 }
209
- }
210
- }
228
+ ```toml
229
+ [[client]]
230
+ role = "planner"
231
+ relay_required = ["writer"] # these roles must have received a handoff
232
+ relay_count = 2 # ... or this many distinct downstream roles
211
233
  ```
212
234
 
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
235
+ The sources rank `environment > relay.toml > none`: `ONLYNE_RELAY_REQUIRED` (the list,
236
+ comma-separated) and `ONLYNE_RELAY_COUNT` (the count, decimal) are the variables the
237
+ client fills from the entry above; a `relay.toml` beside `package.json` is read only
238
+ when the environment names no policy at all; and neither one means no guard. Both
239
+ variables are injected when the spec names both, so the list still wins. A hand-written
240
+ `relay.toml` remains the manual installation's escape hatch — for a box whose spec
241
+ never states the policy — and a file shadowed by the environment is ignored outright. A
242
+ variable that is set but unparsable is reported on stderr and ignored, which gives the
243
+ file its turn.
244
+
245
+ | | |
246
+ | --- | --- |
247
+ | default | neither source names a policy: no guard, and the completion path is the one this plugin shipped before the guard existed |
248
+ | evidence | the roles this session's own successful `onlyne_send` calls reached, `note` and `task` alike; a refused envelope counts for nothing |
249
+ | refusal | `onlyne_complete` throws `onlyne: relay guard: missing handoff to: writer (…)`, naming what is missing and how to clear it |
250
+ | after a refusal | nothing is reported, queued or detached: the session stays mounted, and the same call lands once the handoff has gone out |
251
+ | list mode | every named role must be in the delivered set, literally |
252
+ | count mode | distinct downstream roles; a send to this role itself or back to the role that assigned the task is not one |
253
+ | 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 |
254
+ | waiver | `force: true` with a non-empty `reason`; it only matters when the guard refuses |
255
+ | audit | a waived completion's ledger head starts with `relay-guard-forced: <reason>`, followed by the model's `text` when the call carried one |
256
+ | not guarded | the automatic outcomes: `agent_settled` and `recycle{outcome}` still complete a task that owes a handoff |
257
+
258
+ `relay.toml` is a closed subset of TOML: flat `key = value` lines, the two keys above,
259
+ one-line arrays of double-quoted strings, `#` comments. Anything outside that warns on
260
+ stderr and is ignored. It is deliberately not `.onlyne/config.toml`: the client parses
261
+ that file with `deny_unknown_fields`, so a plugin key there would stop the client from
262
+ starting at all.
263
+
264
+ `force` and `reason` are inert when no policy is in force.
265
+
266
+ ## 6. Protocol notes and deviations
267
+
268
+ Each item below is either a deliberate reading of `PROTOCOL.md` or a behaviour measured on
269
+ the shipped client.
270
+
271
+ - **Report sequence base.** The plugin's own `report` sequence starts at 1000, not 1. The
272
+ client stamps its own dispatch events (`created`, resource attach, `ready`) into the
273
+ same `(generation, seq)` watermark, and the reducer silently drops any report at or
274
+ below it (`crates/onlyne-session/src/reconcile.rs`). A plugin sequence starting at 1
275
+ would lose its first observations. Everything else about the versioning is per spec.
276
+ - **`observed` is a full `Observation`.** `report.heartbeat` carries the whole legal state
277
+ tuple (`version`, `generation_live`, `isolate_after`, `terminate_after`,
278
+ `mismatch_count`, `agent`, `delivery`, `resource`, `recovery`, `outcome`, `public`), not
279
+ a `{"state": "running"}` shorthand: the host deserialises it and rejects anything
280
+ `is_legal` refuses. This plugin owns only the `agent` dimension (turn hooks). It leaves
281
+ `delivery` at `none` and `outcome` at `pending`, which is its own truth until it reports
282
+ a completion. It reports `resource` as `attached` because the host's own dispatch path
283
+ already recorded the attach.
284
+ - **`ready` is reported once per connection.** The host's own hand-off path
285
+ (`crates/onlyne-client/src/dispatch.rs::hand_session`) already reports `ready` when the
286
+ client stages the session for a mounting plugin, so a second report from the plugin is a
287
+ no-op at the host. The plugin sends it anyway: a plugin that mounts *before* any work
288
+ exists is the case the ready barrier names, and it costs one frame.
289
+ - **`cluster_ref` is never sent.** This plugin speaks for a local role, never for an
290
+ aggregate; the field is `skip_serializing_if` absent on the Rust side for the same
291
+ reason.
292
+ - **`probe` is answered with a heartbeat**, per `PROTOCOL.md`'s "a `probe` declares fresh
293
+ resource observations".
294
+ - **`config_get` is read as a task body only when it starts with `stdin:`**, which is the
295
+ overload `PROTOCOL.md` documents for plugins without `inject`. Any other key is logged
296
+ and ignored, never misread.
297
+ - **`frame_too_large` / `bad_frame`**: an oversize body is refused before any byte is
298
+ written, and a framing fault closes the connection and reconnects. Framing cannot
299
+ resynchronise after a corrupt body, which is the same conclusion
300
+ `crates/onlyne-frame/src/lib.rs` reaches.
301
+ - **Task ids here are single-use.** The plugin acks duplicate `assign` deliveries for the
302
+ same task (`reason: "duplicate"`) without a second injection, and remembers the id for
303
+ the life of the connection. The client today mints a fresh uuid per task, so this only
304
+ ever fires on a genuine redelivery.
305
+
306
+ - **Pane binding (Orca tabs).** Inside an Orca pane the plugin reports the pane it runs in on every
307
+ heartbeat, as `observed.host.orca.pane_key` in the report's `Observation`
308
+ (`crates/onlyne-session/src/host.rs`), beside `tab_id` / `leaf_id` and the terminal `handle` when
309
+ the environment names them. The binding is *inherited*, never guessed: an Orca pane exports
310
+ `ORCA_PANE_KEY` / `ORCA_TAB_ID` / `ORCA_LEAF_ID` / `ORCA_TERMINAL_HANDLE` into the command it
311
+ starts (measured 2026-09-11, Orca 1.4.198), and the client passes its own environment on to the
312
+ session command. So the process inside a pane is the only component that can state, from the
313
+ inside, which pane an onlyne session is; nothing downstream of pi can recover that. Outside a
314
+ pane the `host` key is absent altogether: a pi on a plain terminal reports an observation with no
315
+ host field, rather than one with an empty pane.
316
+ - **Nothing is written to the workspace for this.** There is no claim file any more: the binding
317
+ rides the observation the client already mirrors. A stale one cannot exist, because nothing
318
+ creates one, and the workspace's cache directory is not touched. That is what lets
319
+ `integrations/orca-plugin` scope its tab axis to real sessions without reading any path, and what
320
+ lets a supervisor still say where a *finished* session ran: `report.complete` carries `host`
321
+ forward.
322
+
323
+ ## 7. Configuration reference
324
+
325
+ | env var | required | effect |
326
+ | --- | --- | --- |
327
+ | `ONLYNE_ROLE` | yes | the mount role |
328
+ | `ONLYNE_SESSION_ID` | yes | mounted session id; `session_id` equals `task_id` in the shipped client |
329
+ | `ONLYNE_TASK_ID` | yes | the task this process serves; drives `session_register` and the initial `ready` |
330
+ | `ONLYNE_SOCKET` | no | overrides the socket path (default `<cwd>/.onlyne/run/s`) |
331
+ | `ONLYNE_RELAY_REQUIRED` | no | the role's spec `relay_required`, comma-joined: the guard's list mode (§5) |
332
+ | `ONLYNE_RELAY_COUNT` | no | the role's spec `relay_count`: the guard's count mode, which decides only when the list is empty (§5) |
333
+ | `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 |
334
+ | `ORCA_TAB_ID` / `ORCA_LEAF_ID` | no | the pane ids separately; the pane key is parsed when only the key itself is set |
335
+ | `ORCA_TERMINAL_HANDLE` | no | the terminal handle, reported beside the pane key as `host.orca.handle`, and the value `orca terminal switch` takes |
336
+
337
+ Constants worth knowing: the plugin heartbeats every 10 s (`heartbeat_timeout_ms` is 30 s),
338
+ allows 5 s for `hello` and 30 s per request, and reconnects on a 1/2/4/8/16/30 s ladder.
339
+
340
+ The plugin reads two files of its own: `<cwd>/.pi/onlyne.json` (the switch, §1) and
341
+ `relay.toml` next to its `package.json` (the relay policy's fallback, read only when the
342
+ client injected none, §5).
343
+
344
+ ## 8. Troubleshooting
345
+
346
+ | symptom | cause | check |
347
+ | --- | --- | --- |
348
+ | `[pi-onlyne] session …` never appears | one of the three env vars is missing, or `enabled` is false | `env \| grep ONLYNE_`; `cat .pi/onlyne.json` |
349
+ | `socket error: connect ENOENT …/.onlyne/run/s` | no `onlyne-client run` for this workspace | start the client, or `onlyne-client status` |
350
+ | `reconnecting in 4000ms` in a loop | the client is down or the socket was replaced | `onlyne --server-root … roles` |
351
+ | `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 |
352
+ | `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 |
353
+ | 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 |
354
+ | `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 |
355
+ | `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 |
356
+ | `frame_too_large` | a body above 8 MiB | only reachable through an oversize outbound image; the ceiling is the core's |
357
+ | tools missing | `pi.registerTool` is absent in that pi version | `/onlyne status`; the capability table above |
358
+ | 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 |
359
+ | 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 |
360
+
361
+ `/onlyne status` prints the live state (`connected`, `socket`, `role`, `sessionId`,
362
+ `generation`, `agentState`, `tasks`, `pendingCompletion`, `lastError`, counters), and
363
+ `/onlyne connect` / `/onlyne disconnect` open and close the socket by hand.
364
+
365
+ ## 9. Development
226
366
 
227
367
  ```bash
228
- npm install
229
- npm run check
230
- npm pack --dry-run
368
+ cd plugins/onlyne-agent-pi
369
+ node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard
231
370
  ```
232
371
 
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
372
+ `src/agent.live.test.mjs` skips itself unless `target/debug/onlyne-client` and
373
+ `onlyne-server` exist. `crates/onlyne-testkit/e2e/pi-live.sh` is the end-to-end case: it
374
+ skips (exit 0) when pi is absent or has no working model credentials, and otherwise runs
375
+ one real task through a real client to `acked`.
236
376
 
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
377
+ ```bash
378
+ cd ../..
379
+ ONLYNE_BACKEND=fake BIN_DIR=target/debug bash crates/onlyne-testkit/e2e/pi-live.sh
380
+ ```
244
381
 
245
- MIT
382
+ After sourcing the shared helpers, the case exports `ONLYNE_BACKEND=exec`, so the client
383
+ spawns pi itself with a stdin pipe it keeps open for the life of the session. The
384
+ agent's own output lands in `<ws>/.onlyne/logs/session-<task>.log`.