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 +338 -199
- package/README.zh.md +338 -0
- package/onlyne.json.example +6 -0
- package/package.json +23 -35
- package/relay.toml.example +13 -0
- package/src/activity.mjs +96 -0
- package/src/activity.test.mjs +69 -0
- package/src/agent.live.test.mjs +112 -0
- package/src/agent.mjs +963 -0
- package/src/agent.test.mjs +1160 -0
- package/src/config.mjs +74 -0
- package/src/config.test.mjs +88 -0
- package/src/frame.mjs +99 -0
- package/src/frame.test.mjs +97 -0
- package/src/index.ts +303 -0
- package/src/pi-surface.mjs +153 -0
- package/src/protocol.mjs +364 -0
- package/src/protocol.test.mjs +296 -0
- package/src/relay.mjs +299 -0
- package/src/relay.test.mjs +210 -0
- package/LICENSE +0 -21
- package/SPEC.md +0 -124
- package/dist/config.d.ts +0 -32
- package/dist/config.js +0 -19
- package/dist/index.d.ts +0 -27
- package/dist/index.js +0 -780
- package/dist/onlyne.d.ts +0 -70
- package/dist/onlyne.js +0 -194
- package/dist/session.d.ts +0 -82
- package/dist/session.js +0 -91
- package/dist/swarm-prompt.d.ts +0 -12
- package/dist/swarm-prompt.js +0 -45
- package/dist/swarm-slot.d.ts +0 -43
- package/dist/swarm-slot.js +0 -76
- package/dist/swarm.d.ts +0 -96
- package/dist/swarm.js +0 -296
- package/dist/workspace.d.ts +0 -6
- package/dist/workspace.js +0 -14
package/README.md
CHANGED
|
@@ -1,245 +1,384 @@
|
|
|
1
|
-
# pi-onlyne
|
|
1
|
+
# pi-onlyne — the onlyne agent adapter for pi
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
36
|
+
## 1. Install
|
|
40
37
|
|
|
41
|
-
|
|
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
|
-
|
|
41
|
+
### With a generated workspace (the normal path)
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
onlyne
|
|
48
|
-
onlyne
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
60
|
+
onlyne server generate --root <server-root> --out <dir>
|
|
73
61
|
```
|
|
74
62
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
### Manual (no generator)
|
|
122
73
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
###
|
|
79
|
+
### From npm
|
|
172
80
|
|
|
173
|
-
```
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
### One-off / testing
|
|
183
92
|
|
|
184
93
|
```bash
|
|
185
|
-
|
|
94
|
+
pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
|
|
186
95
|
```
|
|
187
96
|
|
|
188
|
-
The
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
[
|
|
196
|
-
|
|
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
|
-
|
|
221
|
+
`relay_required` wins when both keys are present.
|
|
200
222
|
|
|
201
|
-
|
|
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
|
-
```
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
`
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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`.
|