pi-onlyne 0.9.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ // The pi side of the adapter: every effect the agent asks for, expressed
2
+ // through the pi extension API, with a probe for each optional member so an
3
+ // older pi degrades instead of throwing.
4
+ //
5
+ // Probed members (measured against pi 0.85.1):
6
+ // wakeUser pi.sendUserMessage(content, { deliverAs: "followUp" })
7
+ // proseContext pi.sendMessage({customType,...}, { deliverAs:"followUp", triggerTurn:false })
8
+ // customEntry pi.appendEntry(customType, data)
9
+ // status ctx.ui.setStatus("onlyne", text)
10
+ // exit ctx.shutdown()
11
+ // isIdle ctx.isIdle()
12
+ // registerTool / registerCommand are probed by index.ts itself.
13
+
14
+ /**
15
+ * @param {{ pi: any, log: (line: string) => void, context: () => any }} options
16
+ */
17
+ export function createSurface({ pi, log, context }) {
18
+ const has = (value) => typeof value === "function";
19
+ const ctx = () => {
20
+ try {
21
+ return context();
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+
27
+ const available = {
28
+ wakeUser: has(pi.sendUserMessage),
29
+ proseContext: has(pi.sendMessage),
30
+ customEntry: has(pi.appendEntry),
31
+ status: true,
32
+ exit: true,
33
+ isIdle: true,
34
+ registerTool: has(pi.registerTool),
35
+ registerCommand: has(pi.registerCommand),
36
+ };
37
+
38
+ const status = (text) => {
39
+ try {
40
+ ctx()?.ui?.setStatus?.("onlyne", text);
41
+ } catch {
42
+ /* status is decoration; never let it break the protocol */
43
+ }
44
+ };
45
+
46
+ /** One pi user message; images ride along as pi image content parts. */
47
+ const wakeUser = (text, parts = []) => {
48
+ if (!available.wakeUser) {
49
+ log("pi has no sendUserMessage; the assignment reached the session log only");
50
+ return false;
51
+ }
52
+ const content = parts.length === 0
53
+ ? text
54
+ : [
55
+ { type: "text", text },
56
+ ...parts.map((part) => ({
57
+ type: "image",
58
+ source: { type: "base64", mediaType: part.mime, data: part.data },
59
+ })),
60
+ ];
61
+ try {
62
+ pi.sendUserMessage(content, { deliverAs: "followUp" });
63
+ return true;
64
+ } catch (error) {
65
+ try {
66
+ pi.sendUserMessage(content);
67
+ return true;
68
+ } catch (fallbackError) {
69
+ const message = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
70
+ log(`sendUserMessage refused: ${message}`);
71
+ return false;
72
+ }
73
+ }
74
+ };
75
+
76
+ /**
77
+ * The role prose, once, as a custom message that joins the LLM context
78
+ * without starting a turn of its own.
79
+ */
80
+ const proseContext = (text, welcome) => {
81
+ if (!available.proseContext) return false;
82
+ try {
83
+ pi.sendMessage(
84
+ {
85
+ customType: "onlyne-role-prose",
86
+ content: `[onlyne] role prose for ${welcome.role} (from the cluster spec, delivered with welcome):\n\n${text}`,
87
+ display: true,
88
+ },
89
+ { deliverAs: "followUp", triggerTurn: false },
90
+ );
91
+ return true;
92
+ } catch (error) {
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ log(`role prose injection refused: ${message}`);
95
+ return false;
96
+ }
97
+ };
98
+
99
+ const customEntry = (customType, data) => {
100
+ if (!available.customEntry) return false;
101
+ try {
102
+ pi.appendEntry(customType, data);
103
+ return true;
104
+ } catch (error) {
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ log(`appendEntry refused: ${message}`);
107
+ return false;
108
+ }
109
+ };
110
+
111
+ return {
112
+ available,
113
+ wakeUser,
114
+ proseContext,
115
+ customEntry,
116
+ status,
117
+ welcome(welcome) {
118
+ status(`onlyne: ${welcome.role}`);
119
+ },
120
+ /** `true` when pi is not processing a run, retry, compaction, or continuation. */
121
+ isIdle() {
122
+ const current = ctx();
123
+ try {
124
+ return has(current?.isIdle) ? current.isIdle() : true;
125
+ } catch {
126
+ return true;
127
+ }
128
+ },
129
+ exit(reason) {
130
+ log(`exiting pi: ${reason}`);
131
+ try {
132
+ const current = ctx();
133
+ if (has(current?.shutdown)) current.shutdown();
134
+ } catch (error) {
135
+ const message = error instanceof Error ? error.message : String(error);
136
+ log(`shutdown refused: ${message}`);
137
+ }
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,364 @@
1
+ // Wire vocabulary of `crates/onlyne-adapter/PROTOCOL.md`, expressed as plain
2
+ // JSON builders. Nothing here opens a socket or touches pi: every function is a
3
+ // pure translation from plugin state to one frame body, which is what makes the
4
+ // protocol testable against the Rust-side fixtures.
5
+ //
6
+ // Frame envelope, plugin -> host (WireMessage in onlyne-adapter/src/lib.rs):
7
+ // request {"id":N,"op":"...","args":{...}}
8
+ // response {"reply_to":N,"ok":true,"data":{...}} | {"reply_to":N,"ok":false,"error":{code,message,field}}
9
+ // Host -> plugin notifications carry `op`/`args` and no `id`.
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ /** Protocol revision this plugin speaks; `hello.args.protocol`. */
16
+ export const PROTOCOL_VERSION = 1;
17
+ /** Plugin name on the wire; `hello.args.plugin`. */
18
+ export const PLUGIN_NAME = "pi-onlyne";
19
+ /** Mount class; the host matches the payload's field set against its variants. */
20
+ export const MOUNT_KIND = "agent";
21
+ /** Ledger summary ceiling, the same 200 characters `docs/v1-PLAN.md` fixes. */
22
+ export const MAX_HEAD_CHARS = 200;
23
+
24
+ /**
25
+ * Base of the plugin's own report sequence.
26
+ *
27
+ * The host stamps its dispatch events (created, resource attach, ready) into the
28
+ * same `(generation, seq)` watermark the plugin reports against, and the reducer
29
+ * silently drops a report at or below that watermark. The host's own events live
30
+ * in the low single digits, so the plugin's stream starts clear of them; without
31
+ * the offset a turn-state observation would be dropped and never reach the
32
+ * projection.
33
+ */
34
+ export const SEQ_BASE = 1000;
35
+
36
+ /** Heartbeat cadence while a task is active (`heartbeat_timeout_ms` is 30s). */
37
+ export const DEFAULT_HEARTBEAT_MS = 10_000;
38
+
39
+ /** Outcomes the wire accepts for one completion. */
40
+ export const OUTCOMES = ["done", "failed", "cancelled"];
41
+
42
+ /** Mime types `ImagePart` accepts, in the core's stable order. */
43
+ export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
44
+
45
+ /** Decoded ceiling for one inline image, byte-for-byte `IMAGE_DATA_MAX_BYTES`. */
46
+ export const IMAGE_DATA_MAX_BYTES = 2 * 1024 * 1024;
47
+
48
+ /**
49
+ * The plugin's own semver, read from the package next to this file so the
50
+ * vendored copy advertises what its `package.json` says.
51
+ * @returns {string}
52
+ */
53
+ export function readPluginVersion() {
54
+ try {
55
+ const here = dirname(fileURLToPath(import.meta.url));
56
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
57
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
58
+ } catch {
59
+ return "0.0.0";
60
+ }
61
+ }
62
+
63
+ /** One uuid v4, the shape every envelope id and task id takes. */
64
+ export function uuid() {
65
+ return globalThis.crypto.randomUUID();
66
+ }
67
+
68
+ /**
69
+ * `hello.args`, the first frame on the connection. The mount is flat and
70
+ * untagged with `kind` as its sibling, exactly as PROTOCOL.md requires.
71
+ * @param {{ role: string, session?: string | null, taskId?: string | null, pid?: number, capabilities: string[] }} options
72
+ */
73
+ export function helloArgs({ role, session = null, taskId = null, pid, capabilities }) {
74
+ const mount = { role };
75
+ if (session) mount.session = session;
76
+ if (taskId) mount.task_id = taskId;
77
+ if (typeof pid === "number") mount.pid = pid;
78
+ return {
79
+ protocol: PROTOCOL_VERSION,
80
+ plugin: PLUGIN_NAME,
81
+ version: readPluginVersion(),
82
+ kind: MOUNT_KIND,
83
+ capabilities,
84
+ mount,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The `welcome` args the host answers `hello` with. The host sends it inside a
90
+ * response body as `{op:"welcome",args:{...}}`; this accepts either the whole
91
+ * body or the already-unwrapped args object.
92
+ * @param {any} data
93
+ */
94
+ export function welcomeFrom(data) {
95
+ const args = data && typeof data === "object" && data.args && data.op === "welcome" ? data.args : data;
96
+ if (!args || typeof args !== "object" || typeof args.role !== "string") return null;
97
+ return {
98
+ protocol: args.protocol ?? PROTOCOL_VERSION,
99
+ role: args.role,
100
+ sessionId: args.session_id ?? null,
101
+ generation: typeof args.generation === "number" ? args.generation : 1,
102
+ prose: typeof args.prose === "string" ? args.prose : "",
103
+ server: args.server ?? null,
104
+ hostCapabilities: Array.isArray(args.host_capabilities) ? args.host_capabilities : [],
105
+ };
106
+ }
107
+
108
+ /** `report.ready` — the ready barrier the payload waits behind. */
109
+ export function readyReport({ taskId, sessionId, generation, seq }) {
110
+ return { kind: "ready", data: { task_id: taskId, session_id: sessionId, generation, seq } };
111
+ }
112
+
113
+ /**
114
+ * `report.heartbeat`. `observed` is a full `Observation` (`onlyne-session`'s
115
+ * reducer type), not a loose status string: the host deserialises it and rejects
116
+ * anything that is not a legal state tuple.
117
+ */
118
+ export function heartbeatReport({ taskId, generation, seq, agent, host = null }) {
119
+ return {
120
+ kind: "heartbeat",
121
+ data: {
122
+ task_id: taskId,
123
+ generation,
124
+ seq,
125
+ observed: observationFor(agent, { generation, seq, host }),
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The final observation of a settled session: `agent: idle` beside the outcome
132
+ * the completion just stated.
133
+ *
134
+ * A session that only ever reported `running` and then completed leaves the
135
+ * ledger's projection saying `running` forever, because nothing observes the
136
+ * exit. This body is the tuple the host's own settle produces
137
+ * (`onlyne-session/src/reconcile.rs::settle_body`) with the agent dimension
138
+ * moved to `idle`, so `is_legal` accepts it: `outcome: done` requires
139
+ * `delivery: accepted` and an idle agent requires `recovery: draining`, and any
140
+ * other outcome carries the delivery unchanged.
141
+ */
142
+ export function settledReport({ taskId, outcome, generation, seq, host = null }) {
143
+ const normalized = normalizeOutcome(outcome);
144
+ const done = normalized === "done";
145
+ const observed = {
146
+ version: { generation, seq },
147
+ generation_live: true,
148
+ isolate_after: 1,
149
+ terminate_after: 3,
150
+ mismatch_count: 0,
151
+ agent: "idle",
152
+ delivery: done ? "accepted" : "none",
153
+ resource: "attached",
154
+ recovery: done ? "draining" : "none",
155
+ outcome: normalized,
156
+ // `project(idle, accepted, …, done)` is `exited`; every other outcome keeps
157
+ // the session `working` until its resource closes.
158
+ public: done ? "exited" : "working",
159
+ };
160
+ if (host) observed.host = host;
161
+ return { kind: "heartbeat", data: { task_id: taskId, generation, seq, observed } };
162
+ }
163
+
164
+ /** `report.complete` — the terminal fact the ledger keeps. */
165
+ export function completeReport({ taskId, outcome, head }) {
166
+ const report = { kind: "complete", data: { task_id: taskId, outcome: normalizeOutcome(outcome) } };
167
+ const text = headOf(head);
168
+ if (text) report.data.head = text;
169
+ return report;
170
+ }
171
+
172
+ /** `session_register` — bind this live process to its task and session. */
173
+ export function sessionRegisterArgs({ sessionId, taskId = null, generation, pid, title = null }) {
174
+ const args = { session_id: sessionId, generation };
175
+ if (typeof pid === "number") args.pid = pid;
176
+ if (title) args.title = title;
177
+ if (taskId) args.task_id = taskId;
178
+ return args;
179
+ }
180
+
181
+ /** `assign_ack` — the plugin's answer to one `assign`. */
182
+ export function assignAckArgs({ taskId, accepted, reason = null }) {
183
+ const args = { task_id: taskId, accepted };
184
+ if (reason) args.reason = reason;
185
+ return args;
186
+ }
187
+
188
+ /** `detach` — the plugin is leaving while its session continues. */
189
+ export function detachArgs(reason) {
190
+ return { reason };
191
+ }
192
+
193
+ /**
194
+ * A legal `Observation` for one agent state.
195
+ *
196
+ * `onlyne-session`'s `is_legal` requires `public` to be `project(...)` of the
197
+ * other dimensions and non-zero reconcile policy, so the tuple is built rather
198
+ * than passed through: the plugin owns the agent dimension (the host never
199
+ * synthesises turn state), and leaves delivery at `none`/outcome `pending`,
200
+ * which is its own truth until it reports a completion.
201
+ *
202
+ * `host` is where this process runs (`hostBinding`); it is attached only when
203
+ * the environment names a pane, so a pi outside Orca reports a tuple with no
204
+ * host field at all.
205
+ * @param {"booting"|"ready"|"running"|"idle"|"gone"} agent
206
+ */
207
+ export function observationFor(agent, { generation, seq, host = null }) {
208
+ const state = agent === "booting" || agent === "gone" ? "booting" : agent;
209
+ const observed = {
210
+ version: { generation, seq },
211
+ generation_live: true,
212
+ // onlyne-session/src/reconcile.rs: DEFAULT_ISOLATE_AFTER / DEFAULT_TERMINATE_AFTER.
213
+ isolate_after: 1,
214
+ terminate_after: 3,
215
+ mismatch_count: 0,
216
+ agent: state,
217
+ delivery: "none",
218
+ resource: "attached",
219
+ recovery: "none",
220
+ outcome: "pending",
221
+ public: state === "running" ? "working" : state === "ready" || state === "idle" ? "idle" : "created",
222
+ };
223
+ if (host) observed.host = host;
224
+ return observed;
225
+ }
226
+
227
+ /**
228
+ * The Orca pane this process was spawned in, in the shape `onlyne-session`'s
229
+ * `HostRef` deserialises (`{"orca":{…}}`), or null outside a pane.
230
+ *
231
+ * An Orca pane exports `ORCA_PANE_KEY` — `<tab_id>:<leaf_id>`, beside
232
+ * `ORCA_TAB_ID` and `ORCA_TERMINAL_HANDLE` — into the command it was started
233
+ * with (measured 2026-09-11 on Orca 1.4.198), and this plugin is spawned with
234
+ * the environment it inherited, so the binding is inherited rather than
235
+ * guessed: this process is the only component that can state, from the inside,
236
+ * which Orca pane an onlyne session is. No field is invented: the ids come from
237
+ * the key and from the environment, and a missing handle is simply absent.
238
+ *
239
+ * @param {Record<string, string | undefined>} env
240
+ */
241
+ export function hostBinding(env) {
242
+ const named = typeof env?.ORCA_PANE_KEY === "string" ? env.ORCA_PANE_KEY.trim() : "";
243
+ const [keyTab, keyLeaf] = named.includes(":") ? named.split(":") : [];
244
+ const tabId = env?.ORCA_TAB_ID || keyTab || "";
245
+ const leafId = env?.ORCA_LEAF_ID || keyLeaf || "";
246
+ if (!tabId || !leafId) return null;
247
+ const orca = { pane_key: `${tabId}:${leafId}`, tab_id: tabId, leaf_id: leafId };
248
+ const handle = env?.ORCA_TERMINAL_HANDLE;
249
+ if (typeof handle === "string" && handle) orca.handle = handle;
250
+ return { orca };
251
+ }
252
+
253
+ /** One inline image part, from raw bytes. */
254
+ export function imagePart({ data, mime, name = null }) {
255
+ if (!IMAGE_MIMES.includes(mime)) {
256
+ throw new Error(`image mime ${mime} unsupported; expected one of ${IMAGE_MIMES.join(", ")}`);
257
+ }
258
+ if (data.length > IMAGE_DATA_MAX_BYTES) {
259
+ throw new Error(`image exceeds ${IMAGE_DATA_MAX_BYTES} bytes`);
260
+ }
261
+ const part = { data_base64: Buffer.from(data).toString("base64"), mime };
262
+ if (name) part.name = name;
263
+ return part;
264
+ }
265
+
266
+ /** `{"role":{"role":name}}`, the role principal spelling every envelope uses. */
267
+ export function principalRole(role) {
268
+ return { role: { role } };
269
+ }
270
+
271
+ /**
272
+ * One outbound envelope for the `send` frame.
273
+ *
274
+ * `note` is free text and carries no idempotency key; `task` hands work to a
275
+ * role and requires `op_id` plus a causality chain (`Envelope::validate`).
276
+ * @param {{ from: string, to: string, kind?: "note" | "task", text?: string, image?: object | null, taskId?: string | null }} options
277
+ */
278
+ export function sendEnvelope({ from, to, kind = "note", text = "", image = null, taskId = null }) {
279
+ const body = {};
280
+ if (text) body.text = text;
281
+ if (image) body.image = image;
282
+ if (!body.text && !body.image) throw new Error("body requires text or image");
283
+ const envelope = {
284
+ protocol: PROTOCOL_VERSION,
285
+ id: uuid(),
286
+ kind,
287
+ from: principalRole(from),
288
+ to: principalRole(to),
289
+ body,
290
+ ts: new Date().toISOString(),
291
+ admin: false,
292
+ };
293
+ if (kind === "task") {
294
+ envelope.op_id = `o-${uuid()}`;
295
+ envelope.causality = { task: taskId || uuid(), hop: 0, attempt: 0 };
296
+ }
297
+ return envelope;
298
+ }
299
+
300
+ /** A human-readable principal, for injection headers. */
301
+ export function describePrincipal(principal) {
302
+ if (!principal || typeof principal !== "object") return "unknown";
303
+ if (principal.role) {
304
+ const role = principal.role.role ?? "?";
305
+ return principal.role.session ? `role:${role}/${principal.role.session}` : `role:${role}`;
306
+ }
307
+ if (principal.gateway) {
308
+ const { gateway, channel, conversation } = principal.gateway;
309
+ return `gateway:${gateway}:${channel}${conversation ? `:${conversation}` : ""}`;
310
+ }
311
+ if (principal.cluster) return `cluster:${principal.cluster}`;
312
+ return "unknown";
313
+ }
314
+
315
+ /** One line of text, trimmed and cut to the ledger ceiling. */
316
+ export function headOf(text) {
317
+ if (typeof text !== "string") return "";
318
+ const flat = text.replace(/\s+/g, " ").trim();
319
+ return flat.length > MAX_HEAD_CHARS ? flat.slice(0, MAX_HEAD_CHARS) : flat;
320
+ }
321
+
322
+ /** One known outcome spelling, defaulting to `done`. */
323
+ export function normalizeOutcome(value) {
324
+ return typeof value === "string" && OUTCOMES.includes(value) ? value : "done";
325
+ }
326
+
327
+ /**
328
+ * The user message one `assign` becomes.
329
+ *
330
+ * The task text is quoted verbatim under a header naming its origin, so a
331
+ * session transcript shows where the instruction came from; the role prose
332
+ * (identical in `welcome` and `assign`) is folded in only when it has not
333
+ * already been delivered.
334
+ * @param {{ assign: any, proseIsNew: boolean, attachmentPaths?: string[] }} options
335
+ */
336
+ export function injectionText({ assign, proseIsNew, attachmentPaths = [] }) {
337
+ const envelope = assign.envelope ?? {};
338
+ const taskId = assign.task_id ?? envelope.causality?.task ?? "unknown";
339
+ const lines = [
340
+ `[onlyne] task ${taskId} from ${describePrincipal(envelope.from)} (kind ${envelope.kind ?? "task"})`,
341
+ ];
342
+ const prose = typeof assign.prose === "string" ? assign.prose.trim() : "";
343
+ if (prose && proseIsNew) {
344
+ lines.push("", "[onlyne] role prose from the spec:", prose);
345
+ }
346
+ const text = typeof envelope.body?.text === "string" ? envelope.body.text.trim() : "";
347
+ lines.push("", text || "(the task carried no text; the image attachment is the payload)");
348
+ if (attachmentPaths.length > 0) {
349
+ lines.push("", `[onlyne] attachment saved to: ${attachmentPaths.join(", ")}`);
350
+ }
351
+ return lines.join("\n");
352
+ }
353
+
354
+ /**
355
+ * Task body for the `config_get` route: when a plugin does not declare `inject`
356
+ * the host hands the payload over as `config_get{key:"stdin:<task text>"}`
357
+ * (PROTOCOL.md, "Mounts and capabilities").
358
+ * @returns {{ text: string } | null}
359
+ */
360
+ export function stdinTaskText(args) {
361
+ const key = args?.key;
362
+ if (typeof key !== "string" || !key.startsWith("stdin:")) return null;
363
+ return { text: key.slice("stdin:".length) };
364
+ }