pi-onlyne

Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.

Packages

Package details

extension

Install pi-onlyne from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-onlyne
Package
pi-onlyne
Version
1.1.2
Published
Sep 18, 2026
Downloads
1,063/mo · 722/wk
Author
dbydd
License
MIT
Types
extension
Size
230.9 KB
Dependencies
0 dependencies · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-onlyne — the onlyne agent adapter for pi

This pi extension makes one pi process serve one onlyne role session. It connects to <role workspace>/.onlyne/run/s, speaks the adapter protocol in crates/onlyne-adapter/PROTOCOL.md, and drives a session through hello → welcome → assign → work → complete → detach. No Rust code runs here: the protocol is reimplemented on Node's node:net, with a hand-written four-byte length-prefixed JSON codec, and the runtime has no npm dependencies.

Outside an onlyne session the extension is inert. The client injects ONLYNE_ROLE, ONLYNE_SESSION_ID and ONLYNE_TASK_ID into every process it spawns (crates/onlyne-client/src/dispatch.rs). With any of the three missing, this is an ordinary pi session: the plugin registers nothing and opens nothing.

pi session (spawned by onlyne-client)
  │  env: ONLYNE_ROLE / ONLYNE_SESSION_ID / ONLYNE_TASK_ID
  │  .pi/onlyne.json: { "enabled": true, "watch": { "autoStart": true } }
  ▼
hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{role,session,task_id,pid}}
  ◀── welcome{role, prose, generation, server, host_capabilities}
  ├─ prose ──► pi context, once (custom message, no turn)
  ├─ report.ready ──► the barrier the task payload waits behind
  ◀── assign{envelope, prose, task_id, generation}
  ├─ task text (+ image path) ──► pi user message (deliverAs:"followUp")
  ├─ assign_ack{accepted:true}
  ├─ report.heartbeat{running|idle} — per turn, and every 10s while a task is live
  ├─ report.complete{outcome, head} — the ledger's terminal fact
  │    └─ then one report.heartbeat{agent:"idle"} carrying the settled tuple
  │       └─ the client's answer is the handover: pi is asked to shut down, then detaches
  ├─ probe ──► one heartbeat
  ◀── recycle ──► complete (if unsettled) → stop → pi exits
  └─ detach{reason} when pi shuts down

1. Install

The plugin is a pi package: package.json declares pi.extensions: ["./src/index.ts"], so pi loads the TypeScript source directly (no build step).

With a generated workspace (the normal path)

onlyne server generate copies [server].agent_package into <ws>/.onlyne/agent/<pkg-name>/, and writes that package into .pi/settings.json as a path relative to the settings file itself: ../.onlyne/agent/<pkg-name> (crates/onlyne-server/src/generate.rs). pi 0.85.1 loads only that spelling. A project packages path resolves against the directory holding the settings file (<ws>/.pi), so the ../ form reaches <ws>/.onlyne/agent/<pkg-name>, while a bare .onlyne/agent/<pkg-name> entry would resolve to <ws>/.pi/.onlyne/agent/<pkg-name> and list the package without loading it. A supervisor starts the generated workspace, and the extension travels with it: nothing is installed globally.

# spec.toml
[server]
agent_package = "/abs/path/to/plugins/onlyne-agent-pi"   # read once, at generate time
onlyne server generate --root <server-root> --out <dir>

The generated .pi/settings.json then carries:

{ "packages": ["../.onlyne/agent/onlyne-agent-pi"] }

pi list shows the entry under "Project packages". To verify the load itself, make the copied index.ts throw and watch for the failure.

Manual (no generator)

cp -R plugins/onlyne-agent-pi <ws>/.onlyne/agent/onlyne-agent-pi
printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings.json

From npm

pi install npm:pi-onlyne          # user-level: every pi process on this box loads it

The published package is pi-onlyne on npm; pi install npm:pi-onlyne@<version> pins one. This route reaches ordinary interactive sessions too, and there the extension stays inert (no ONLYNE_ROLE, so no adapter). A role workspace needs no global install to get a panel: the file-level copy above, or onlyne server generate, scopes the plugin to the workspace that serves the role.

One-off / testing

pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc

The switch file

<cwd>/.pi/onlyne.json (see onlyne.json.example):

key default effect
enabled true false turns the extension off for this workspace
watch.autoStart true false registers the tools but opens no socket until /onlyne connect

A missing file means both defaults. A malformed file prints one warning on stderr and keeps both defaults: a typo must not silently disable a role. The client does not read this file (§11 of the plan downgraded the old readiness gates to generate-time template advice), so only this extension consumes it; the key shape stays the one the templates carry.

Nothing else is needed. The workspace's session_command in spec.toml already spawns pi per task (["pi", "--session-id", "{session}"]), and the client injects the environment this extension keys on.

2. Capabilities

The hello frame declares what this plugin actually implements:

capability declared what it means here
register always session_register{session_id, task_id, generation, pid, title} after welcome
report always report.ready / report.heartbeat / report.complete
inject when pi.sendUserMessage exists the payload arrives as assign and is injected as a pi user message
recycle always recycle settles the task if it is unsettled, then stops the plugin and exits pi

What happens when a pi API is missing, and what the host does then:

gap detection behaviour
no registerTool (older pi) probed at session_start no tools are registered; the protocol path is unaffected, and /onlyne status still works
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
no sendMessage probed the role prose from welcome is not injected as context; the task itself still arrives
no appendEntry probed no onlyne-assign / onlyne-complete session entries are recorded
no ui.setStatus guarded the footer status line is skipped
no ui.setWidget guarded routine notices continue through the footer status line and the [pi-onlyne] stderr line
no ctx.shutdown guarded recycle and a completion still settle the task; the process stays up for the operator to close

Activity panel

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.

3. Tools

Registered only inside an onlyne session.

onlyne_send{to, text, kind?, image?}

Sends one envelope on the send frame. kind: "note" (the default) is free text and carries no op_id. kind: "task" hands work to a role, so it carries an o-<uuid> idempotency key and a fresh causality.task. image is an absolute path to a png/jpeg/gif/webp file: the plugin reads it, base64-encodes it and attaches it as body.image. The core caps that at 2 MiB and accepts four mime types.

onlyne_complete{outcome?, text?, force?, reason?}

Ends the current task with an explicit outcome (done by default, or failed). A non-empty text becomes the ledger head verbatim: whitespace collapses to one line and the text stops at 200 characters. An absent or blank text carries no summary, so the completion falls back to the last assistant text. The call also ends the session's process: once the client has acknowledged the completion report (see §4), the plugin asks pi to shut down through ctx.shutdown(). pi 0.85.1 has no tool-result terminate handling. When the workspace carries a relay policy (§5), force: true with a non-empty reason is the deliberate way past a handoff the session still owes.

4. Outcome rules

The plugin sends one completion per task, at the first of these events:

  1. onlyne_complete — the model gives an explicit outcome. It wins over everything else, and a later completion for the same task is refused (not re-reported). Its non-empty text is the head.
  2. agent_settled — pi will not continue on its own: no retry, compaction or queued continuation is pending. The plugin reports:
    • failed when the turn ended with a provider error (stopReason: "error"), with the error as the head;
    • done otherwise, with the last assistant text as the head;
    • nothing at all when the task was assigned but no turn has run yet. The injected message has not executed, so completing now would claim work that never happened.
  3. recycle{outcome} — the host is tearing the session down. The plugin settles an unsettled task with the host's outcome first, then stops and exits pi.

head is a single line, capped at 200 characters; it matches what the client puts in out_head and what the receipt carries. Each task has one source for it: the text of the explicit onlyne_complete call when that call carried one, and the last assistant text otherwise. The auto rule is that fallback path: it reports the text of the turn it settles, and a sentence spoken after the call cannot replace what the call handed over.

A reported completion ends the session's process. report.complete goes out as a request, and the client answers it only after it has settled the session row, acked the delivery and written the Completion envelope. The plugin asks pi to shut down at that answer. An outcome the socket could not carry is queued and flushed after the next hello, and that flush's answer is the handover that ends the process. A completion the host refused leaves the process running, so an exit never loses the task.

The last report is one observation with agent: "idle" beside the settled outcome, sent after the completion is acknowledged and before the process leaves. The completion settles the row from the tuple the client holds, and that tuple still reads running when the finishing turn was the last heartbeat. Nothing observes the process afterwards, so without this report an exited session keeps saying running. The plugin skips it when the last beat was already idle, and a refused settled observation does not hold up the exit the completion earned.

5. Relay guard

A session can hand no work over and still report done. That is the accident the guard closes: a bench session narrated its progress, called onlyne_complete with its todos untouched, and the downstream writer waited for a handoff that was never sent. The guard judges delivery facts only — whether a role was reached — and never the shape or quality of the text that was sent.

The policy lives next to the plugin's package.json, so it travels inside the copy a generated workspace loads: <ws>/.onlyne/agent/onlyne-agent-pi/relay.toml in a generated workspace, relay.toml in a manual installation.

relay_required = ["writer"]        # these roles must have received a handoff
relay_required_count = 2           # legacy alias of relay_count: this many distinct downstream roles

relay_count is the canonical count key. relay_required_count is its legacy alias, the spelling relay.toml itself uses. relay_required wins when both the list and the count keys are present.

The policy belongs in the spec, not in the vendor directory. onlyne generate --force rewrites the copy this package is vendored into and takes a hand-written relay.toml with it, so a [[client]] entry states the policy once and the client injects it into every session process it spawns:

[[client]]
role = "planner"
relay_count = 2                    # this many distinct downstream roles
relay_required = ["writer"]        # these roles must have received a handoff

The sources rank environment > relay.toml > none: ONLYNE_RELAY_REQUIRED (the list, comma-separated) and ONLYNE_RELAY_COUNT (the count, decimal) are the variables the client fills from the entry above; a relay.toml beside package.json is read only when the environment names no policy at all; and neither one means no guard. Both variables are injected when the spec names both, so the list still wins. A hand-written relay.toml remains the manual installation's escape hatch — for a box whose spec never states the policy — and a file shadowed by the environment is ignored outright. A variable that is set but unparsable is reported on stderr and ignored, which gives the file its turn.

default neither source names a policy: no guard, and the completion path is the one this plugin shipped before the guard existed
evidence the roles this session's own successful onlyne_send calls reached, note and task alike; a refused envelope counts for nothing
refusal onlyne_complete throws onlyne: relay guard: missing handoff to: writer (…), naming what is missing and how to clear it
after a refusal nothing is reported, queued or detached: the session stays mounted, and the same call lands once the handoff has gone out
list mode every named role must be in the delivered set, literally
count mode distinct downstream roles; a send to this role itself or back to the role that assigned the task is not one
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
waiver force: true with a non-empty reason; it only matters when the guard refuses
audit a waived completion's ledger head starts with relay-guard-forced: <reason>, followed by the model's text when the call carried one
not guarded the automatic outcomes: agent_settled and recycle{outcome} still complete a task that owes a handoff

relay.toml is a closed subset of TOML: flat key = value lines, the two keys above, one-line arrays of double-quoted strings, # comments. Anything outside that warns on stderr and is ignored. It is deliberately not .onlyne/config.toml: the client parses that file with deny_unknown_fields, so a plugin key there would stop the client from starting at all.

force and reason are inert when no policy is in force.

6. Protocol notes and deviations

Each item below is either a deliberate reading of PROTOCOL.md or a behaviour measured on the shipped client.

  • Report sequence base. The plugin's own report sequence starts at 1000, not 1. The client stamps its own dispatch events (created, resource attach, ready) into the same (generation, seq) watermark, and the reducer silently drops any report at or below it (crates/onlyne-session/src/reconcile.rs). A plugin sequence starting at 1 would lose its first observations. Everything else about the versioning is per spec.

  • observed is a full Observation. report.heartbeat carries the whole legal state tuple (version, generation_live, isolate_after, terminate_after, mismatch_count, agent, delivery, resource, recovery, outcome, public), not a {"state": "running"} shorthand: the host deserialises it and rejects anything is_legal refuses. This plugin owns only the agent dimension (turn hooks). It leaves delivery at none and outcome at pending, which is its own truth until it reports a completion. It reports resource as attached because the host's own dispatch path already recorded the attach.

  • ready is reported once per connection. The host's own hand-off path (crates/onlyne-client/src/dispatch.rs::hand_session) already reports ready when the client stages the session for a mounting plugin, so a second report from the plugin is a no-op at the host. The plugin sends it anyway: a plugin that mounts before any work exists is the case the ready barrier names, and it costs one frame.

  • cluster_ref is never sent. This plugin speaks for a local role, never for an aggregate; the field is skip_serializing_if absent on the Rust side for the same reason.

  • probe is answered with a heartbeat, per PROTOCOL.md's "a probe declares fresh resource observations".

  • config_get is read as a task body only when it starts with stdin:, which is the overload PROTOCOL.md documents for plugins without inject. Any other key is logged and ignored, never misread.

  • frame_too_large / bad_frame: an oversize body is refused before any byte is written, and a framing fault closes the connection and reconnects. Framing cannot resynchronise after a corrupt body, which is the same conclusion crates/onlyne-frame/src/lib.rs reaches.

  • Deliveries are idempotent; tasks are not. The dedup key is the envelope id. The same delivery twice gets one injection and an ack with reason: "duplicate", and a new envelope for a task that is already running reaches that session as another message — the work record keeps its counters and its relay ledger, and only its "turns since this instruction" watchdog restarts. The client mints a fresh uuid per envelope, so duplicate fires on a genuine re-offer and on nothing else.

  • Pane binding (Orca tabs). Inside an Orca pane the plugin reports the pane it runs in on every heartbeat, as observed.host.orca.pane_key in the report's Observation (crates/onlyne-session/src/host.rs), beside tab_id / leaf_id and the terminal handle when the environment names them. The binding is inherited, never guessed: an Orca pane exports ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_LEAF_ID / ORCA_TERMINAL_HANDLE into the command it starts (measured 2026-09-11, Orca 1.4.198), and the client passes its own environment on to the session command. So the process inside a pane is the only component that can state, from the inside, which pane an onlyne session is; nothing downstream of pi can recover that. Outside a pane the host key is absent altogether: a pi on a plain terminal reports an observation with no host field, rather than one with an empty pane.

  • Nothing is written to the workspace for this. There is no claim file any more: the binding rides the observation the client already mirrors. A stale one cannot exist, because nothing creates one, and the workspace's cache directory is not touched. That is what lets integrations/orca-plugin scope its tab axis to real sessions without reading any path, and what lets a supervisor still say where a finished session ran: report.complete carries host forward.

7. Configuration reference

env var required effect
ONLYNE_ROLE yes the mount role
ONLYNE_SESSION_ID yes mounted session id; session_id equals task_id in the shipped client
ONLYNE_TASK_ID yes the task this process serves; drives session_register and the initial ready
ONLYNE_SOCKET no the socket the client serves for this workspace, injected into every session process it spawns; with the variable unset the plugin reads the marker <cwd>/.onlyne/run/socket for the path the daemon published, and falls back to <cwd>/.onlyne/run/s
ONLYNE_RELAY_REQUIRED no the role's spec relay_required, comma-joined: the guard's list mode (§5)
ONLYNE_RELAY_COUNT no the role's spec relay_count: the guard's count mode, which decides only when the list is empty (§5)
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
ORCA_TAB_ID / ORCA_LEAF_ID no the pane ids separately; the pane key is parsed when only the key itself is set
ORCA_TERMINAL_HANDLE no the terminal handle, reported beside the pane key as host.orca.handle, and the value orca terminal switch takes

Constants worth knowing: the plugin heartbeats every 10 s (heartbeat_timeout_ms is 30 s), allows 5 s for hello and 30 s per request, and reconnects on a 1/2/4/8/16/30 s ladder.

The plugin reads three files of its own: <cwd>/.pi/onlyne.json (the switch, §1), relay.toml next to its package.json (the relay policy's fallback, read only when the client injected none, §5), and <cwd>/.onlyne/run/socket (the marker naming the socket path the client's daemon bound, read when the environment carried none, §8).

8. Troubleshooting

symptom cause check
[pi-onlyne] session … never appears one of the three env vars is missing, or enabled is false env | grep ONLYNE_; cat .pi/onlyne.json
socket error: connect ENOENT …/.onlyne/run/s no onlyne-client run for this workspace start the client, or onlyne-client status
socket error: connect EINVAL …/.onlyne/run/s on a deep workspace macOS gives sun_path 104 bytes, so a socket path past 103 is refused; a generated role workspace nests three levels under its server root and a long root carries the canonical spelling over the bound. The client serves such a workspace from a short path under the temporary directory and publishes it in <workspace>/.onlyne/run/socket onlyne-client status for the line onlyne: client running … socket <path>, which names the served path, plus the client log line carrying socket = <path>; cat <workspace>/.onlyne/run/socket holds that same path, and the plugin dials it when the environment injected nothing
reconnecting in 4000ms in a loop the client is down or the socket was replaced onlyne --server-root … roles
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
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
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
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
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
frame_too_large a body above 8 MiB only reachable through an oversize outbound image; the ceiling is the core's
tools missing pi.registerTool is absent in that pi version /onlyne status; the capability table above
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
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

/onlyne status prints the live state (connected, socket, role, sessionId, generation, agentState, tasks, pendingCompletion, lastError, counters), and /onlyne connect / /onlyne disconnect open and close the socket by hand.

9. Development

cd plugins/onlyne-agent-pi
node --test src/*.test.mjs        # framing, protocol, agent state machine, config, relay guard, socket path

src/agent.live.test.mjs skips itself unless target/debug/onlyne-client and onlyne-server exist. crates/onlyne-testkit/e2e/pi-live.sh is the end-to-end case: it skips (exit 0) when pi is absent or has no working model credentials, and otherwise runs one real task through a real client to acked.

cd ../..
ONLYNE_BACKEND=fake BIN_DIR=target/debug bash crates/onlyne-testkit/e2e/pi-live.sh

After sourcing the shared helpers, the case exports ONLYNE_BACKEND=exec, so the client spawns pi itself with a stdin pipe it keeps open for the life of the session. The agent's own output lands in <ws>/.onlyne/logs/session-<task>.log.