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.
package/src/config.mjs ADDED
@@ -0,0 +1,74 @@
1
+ // `.pi/onlyne.json`, the plugin's own switch file.
2
+ //
3
+ // The workspace layout keeps the old key shape (`watch.autoStart`) because that
4
+ // is what the generated role templates and operator habits carry; the client
5
+ // does not read this file at all (§11 line 399 downgraded the readiness gates to
6
+ // generate-time template advice), so the only consumer is this extension. A
7
+ // malformed or missing file falls back to the defaults and reports a warning
8
+ // instead of disabling the session: the extension's own `enabled` key is the one
9
+ // deliberate off switch.
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ /** Where the switch file lives, relative to the pi working directory. */
15
+ export const CONFIG_RELATIVE_PATH = join(".pi", "onlyne.json");
16
+
17
+ /** Defaults: on, and connecting as soon as a session starts. */
18
+ export const DEFAULT_CONFIG = Object.freeze({ enabled: true, autoStart: true });
19
+
20
+ /**
21
+ * Read `.pi/onlyne.json`.
22
+ *
23
+ * @param {string} cwd
24
+ * @param {{ readFile?: (path: string) => string }} [options]
25
+ * @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
26
+ */
27
+ export function loadConfig(cwd, options = {}) {
28
+ const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
29
+ const path = join(cwd, CONFIG_RELATIVE_PATH);
30
+ let raw;
31
+ try {
32
+ raw = readFile(path);
33
+ } catch {
34
+ return { ...DEFAULT_CONFIG, path, warning: null, present: false };
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(raw);
39
+ } catch (error) {
40
+ return {
41
+ ...DEFAULT_CONFIG,
42
+ path,
43
+ warning: `${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults`,
44
+ present: true,
45
+ };
46
+ }
47
+ if (!parsed || typeof parsed !== "object") {
48
+ return { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
49
+ }
50
+ const watch = parsed.watch && typeof parsed.watch === "object" ? parsed.watch : {};
51
+ return {
52
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
53
+ autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
54
+ path,
55
+ warning: null,
56
+ present: true,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * The session identity the client injects into a spawned agent process
62
+ * (`crates/onlyne-client/src/dispatch.rs`). All three are required: with any one
63
+ * missing this is a plain pi session and the extension stays out of the way.
64
+ *
65
+ * @param {Record<string, string | undefined>} env
66
+ * @returns {{ role: string, sessionId: string, taskId: string } | null}
67
+ */
68
+ export function sessionIdentity(env) {
69
+ const role = env.ONLYNE_ROLE;
70
+ const sessionId = env.ONLYNE_SESSION_ID;
71
+ const taskId = env.ONLYNE_TASK_ID;
72
+ if (!role || !sessionId || !taskId) return null;
73
+ return { role, sessionId, taskId };
74
+ }
@@ -0,0 +1,88 @@
1
+ // `.pi/onlyne.json` and the session-identity gate.
2
+ //
3
+ // The identity gate is the hard requirement that lets one pi installation serve
4
+ // both onlyne sessions and ordinary ones: with any of the three variables
5
+ // missing the extension must stay inert.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { afterEach, test } from "node:test";
14
+
15
+ import { CONFIG_RELATIVE_PATH, DEFAULT_CONFIG, loadConfig, sessionIdentity } from "./config.mjs";
16
+
17
+ const cleanups = [];
18
+ afterEach(() => {
19
+ while (cleanups.length > 0) cleanups.pop()();
20
+ });
21
+
22
+ /** One temp workspace, optionally carrying a `.pi/onlyne.json` body. */
23
+ function workspace(body) {
24
+ const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-config-"));
25
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
26
+ if (body !== undefined) {
27
+ mkdirSync(join(dir, ".pi"), { recursive: true });
28
+ writeFileSync(join(dir, CONFIG_RELATIVE_PATH), body);
29
+ }
30
+ return dir;
31
+ }
32
+
33
+ test("a missing switch file means enabled and autoStart", () => {
34
+ const dir = workspace();
35
+ const config = loadConfig(dir);
36
+ assert.deepEqual(
37
+ { enabled: config.enabled, autoStart: config.autoStart },
38
+ { enabled: DEFAULT_CONFIG.enabled, autoStart: DEFAULT_CONFIG.autoStart },
39
+ );
40
+ assert.equal(config.present, false);
41
+ assert.equal(config.warning, null);
42
+ assert.equal(config.path, join(dir, ".pi", "onlyne.json"));
43
+ });
44
+
45
+ test("the switch file's two keys are honoured", () => {
46
+ const dir = workspace(JSON.stringify({ enabled: false, watch: { autoStart: false } }));
47
+ const config = loadConfig(dir);
48
+ assert.equal(config.enabled, false);
49
+ assert.equal(config.autoStart, false);
50
+ assert.equal(config.present, true);
51
+
52
+ const partial = workspace(JSON.stringify({ watch: { autoStart: false } }));
53
+ assert.deepEqual(
54
+ { enabled: loadConfig(partial).enabled, autoStart: loadConfig(partial).autoStart },
55
+ { enabled: true, autoStart: false },
56
+ );
57
+ });
58
+
59
+ test("a malformed switch file warns and falls back instead of disabling the session", () => {
60
+ const broken = workspace("{not json");
61
+ const brokenConfig = loadConfig(broken);
62
+ assert.equal(brokenConfig.enabled, true);
63
+ assert.equal(brokenConfig.autoStart, true);
64
+ assert.match(brokenConfig.warning, /not valid JSON/);
65
+
66
+ const scalar = workspace('"on"');
67
+ const scalarConfig = loadConfig(scalar);
68
+ assert.equal(scalarConfig.enabled, true);
69
+ assert.match(scalarConfig.warning, /must hold a JSON object/);
70
+ });
71
+
72
+ test("the session identity gate needs all three variables", () => {
73
+ const full = { ONLYNE_ROLE: "planner", ONLYNE_SESSION_ID: "s1", ONLYNE_TASK_ID: "t1" };
74
+ assert.deepEqual(sessionIdentity(full), { role: "planner", sessionId: "s1", taskId: "t1" });
75
+ for (const missing of ["ONLYNE_ROLE", "ONLYNE_SESSION_ID", "ONLYNE_TASK_ID"]) {
76
+ const partial = { ...full };
77
+ delete partial[missing];
78
+ assert.equal(sessionIdentity(partial), null, `${missing} alone must disable the plugin`);
79
+ }
80
+ assert.equal(sessionIdentity({}), null);
81
+ assert.equal(sessionIdentity({ ONLYNE_ROLE: "", ONLYNE_SESSION_ID: "s", ONLYNE_TASK_ID: "t" }), null);
82
+ });
83
+
84
+ test("the shipped switch file documents only the keys the plugin reads", () => {
85
+ const path = fileURLToPath(new URL("../onlyne.json.example", import.meta.url));
86
+ const example = JSON.parse(readFileSync(path, "utf8"));
87
+ assert.deepEqual(example, { enabled: true, watch: { autoStart: true } });
88
+ });
package/src/frame.mjs ADDED
@@ -0,0 +1,99 @@
1
+ // Length-prefixed JSON framing, the onlyne-frame codec: a four-byte big-endian
2
+ // u32 body length followed by one UTF-8 JSON object. Zero dependencies: Node's
3
+ // Buffer is the whole implementation.
4
+ //
5
+ // Failure modes mirror `crates/onlyne-frame/src/lib.rs` so the plugin's log
6
+ // speaks the same vocabulary the host does: an oversize length prefix is
7
+ // `frame_too_large`, undecodable JSON is `bad_frame`.
8
+
9
+ /** Hard ceiling for one frame body, byte-for-byte `MAX_FRAME_BYTES`. */
10
+ export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
11
+
12
+ /**
13
+ * Encode one frame. Throws before any byte leaves the writer, so an oversize
14
+ * payload never puts a partial frame on the wire.
15
+ * @param {unknown} value
16
+ * @returns {Buffer}
17
+ */
18
+ export function encodeFrame(value) {
19
+ const body = Buffer.from(JSON.stringify(value), "utf8");
20
+ if (body.length > MAX_FRAME_BYTES) {
21
+ throw new Error(`frame_too_large: ${body.length} > ${MAX_FRAME_BYTES}`);
22
+ }
23
+ const header = Buffer.allocUnsafe(4);
24
+ header.writeUInt32BE(body.length, 0);
25
+ return Buffer.concat([header, body]);
26
+ }
27
+
28
+ /**
29
+ * Decode one framed body. Throws the same two error shapes `encodeFrame` and
30
+ * the streaming decoder raise, for tests and for callers holding a whole frame.
31
+ * @param {Buffer} frame
32
+ * @returns {unknown}
33
+ */
34
+ export function decodeFrame(frame) {
35
+ if (frame.length < 4) throw new Error("bad_frame: truncated length prefix");
36
+ const length = frame.readUInt32BE(0);
37
+ if (length > MAX_FRAME_BYTES) throw new Error(`frame_too_large: ${length} > ${MAX_FRAME_BYTES}`);
38
+ if (frame.length !== 4 + length) throw new Error("bad_frame: truncated body");
39
+ try {
40
+ return JSON.parse(frame.subarray(4).toString("utf8"));
41
+ } catch (error) {
42
+ throw new Error(`bad_frame: ${error instanceof Error ? error.message : String(error)}`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A streaming decoder. `push` accepts whatever the socket delivered — half a
48
+ * frame, several frames, or both — and calls `onFrame` once per complete body,
49
+ * in order.
50
+ *
51
+ * The decoder dies on the first framing fault (oversize, undecodable body) the
52
+ * way the connection must: framing cannot resynchronise after a corrupt body,
53
+ * so `onError` is called once and every later `push` is a no-op.
54
+ *
55
+ * @param {{ maxBytes?: number, onFrame: (value: unknown) => void, onError?: (error: Error) => void }} options
56
+ */
57
+ export function createFrameDecoder({ maxBytes = MAX_FRAME_BYTES, onFrame, onError }) {
58
+ let buffer = Buffer.alloc(0);
59
+ let dead = false;
60
+ const fail = (error) => {
61
+ dead = true;
62
+ buffer = Buffer.alloc(0);
63
+ onError?.(error);
64
+ };
65
+ return {
66
+ /** @param {Buffer} chunk */
67
+ push(chunk) {
68
+ if (dead || chunk.length === 0) return;
69
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
70
+ for (;;) {
71
+ if (buffer.length < 4) return;
72
+ const length = buffer.readUInt32BE(0);
73
+ if (length > maxBytes) {
74
+ fail(new Error(`frame_too_large: ${length} > ${maxBytes}`));
75
+ return;
76
+ }
77
+ if (buffer.length < 4 + length) return;
78
+ const body = buffer.subarray(4, 4 + length);
79
+ buffer = buffer.subarray(4 + length);
80
+ let value;
81
+ try {
82
+ value = JSON.parse(body.toString("utf8"));
83
+ } catch (error) {
84
+ fail(new Error(`bad_frame: ${error instanceof Error ? error.message : String(error)}`));
85
+ return;
86
+ }
87
+ onFrame(value);
88
+ }
89
+ },
90
+ /** Bytes held back waiting for the rest of a frame. */
91
+ buffered() {
92
+ return buffer.length;
93
+ },
94
+ /** Whether a framing fault has closed this decoder. */
95
+ dead() {
96
+ return dead;
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,97 @@
1
+ // Framing tests against the Rust encoder's own wire vectors.
2
+
3
+ import assert from "node:assert/strict";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { test } from "node:test";
7
+
8
+ import { MAX_FRAME_BYTES, createFrameDecoder, decodeFrame, encodeFrame } from "./frame.mjs";
9
+
10
+ const VECTOR_DIR = fileURLToPath(new URL("../../../crates/onlyne-proto/tests/wire_vectors/", import.meta.url));
11
+ const hasVectors = existsSync(VECTOR_DIR);
12
+ const vector = (name) => JSON.parse(readFileSync(`${VECTOR_DIR}${name}`, "utf8"));
13
+
14
+ /** Collect every frame one decoder yields from a sequence of chunks. */
15
+ function decodeAll(chunks, options = {}) {
16
+ const frames = [];
17
+ const errors = [];
18
+ const decoder = createFrameDecoder({
19
+ onFrame: (value) => frames.push(value),
20
+ onError: (error) => errors.push(error),
21
+ ...options,
22
+ });
23
+ for (const chunk of chunks) decoder.push(chunk);
24
+ return { frames, errors, decoder };
25
+ }
26
+
27
+ test("a frame round trips through encode and decode", () => {
28
+ const value = { id: 1, op: "hello", args: { protocol: 1, capabilities: ["register", "report"] } };
29
+ const frame = encodeFrame(value);
30
+ assert.equal(frame.readUInt32BE(0), frame.length - 4);
31
+ assert.deepEqual(decodeFrame(frame), value);
32
+ assert.deepEqual(decodeAll([frame]).frames, [value]);
33
+ });
34
+
35
+ test("a half frame is held until its remainder arrives", () => {
36
+ const frame = encodeFrame({ op: "assign", args: { task_id: "t1" } });
37
+ const first = decodeAll([frame.subarray(0, 4)]);
38
+ assert.deepEqual(first.frames, []);
39
+ assert.equal(first.decoder.buffered(), 4);
40
+ const rest = decodeAll([frame.subarray(0, 6), frame.subarray(6)]);
41
+ assert.deepEqual(rest.frames, [{ op: "assign", args: { task_id: "t1" } }]);
42
+ });
43
+
44
+ test("several frames in one chunk are emitted in order", () => {
45
+ const frames = [encodeFrame({ id: 1 }), encodeFrame({ id: 2 }), encodeFrame({ id: 3 })];
46
+ const { frames: decoded } = decodeAll([Buffer.concat(frames)]);
47
+ assert.deepEqual(decoded, [{ id: 1 }, { id: 2 }, { id: 3 }]);
48
+ });
49
+
50
+ test("a frame split across many chunks still decodes once", () => {
51
+ const frame = encodeFrame({ op: "report", args: { kind: "heartbeat", data: { seq: 1001 } } });
52
+ const chunks = [...frame].map((byte) => Buffer.from([byte]));
53
+ const { frames, errors } = decodeAll(chunks);
54
+ assert.deepEqual(errors, []);
55
+ assert.deepEqual(frames, [{ op: "report", args: { kind: "heartbeat", data: { seq: 1001 } } }]);
56
+ });
57
+
58
+ test("an oversize length prefix kills the decoder without emitting a frame", () => {
59
+ const header = Buffer.alloc(4);
60
+ header.writeUInt32BE(MAX_FRAME_BYTES + 1, 0);
61
+ const { frames, errors, decoder } = decodeAll([header]);
62
+ assert.deepEqual(frames, []);
63
+ assert.equal(errors.length, 1);
64
+ assert.match(errors[0].message, /^frame_too_large: /);
65
+ // A dead decoder stays dead: framing cannot resynchronise after a corrupt length.
66
+ assert.equal(decoder.dead(), true);
67
+ assert.equal(decoder.buffered(), 0);
68
+ });
69
+
70
+ test("undecodable JSON is a bad_frame fault", () => {
71
+ const body = Buffer.from("{not json", "utf8");
72
+ const header = Buffer.alloc(4);
73
+ header.writeUInt32BE(body.length, 0);
74
+ const { frames, errors } = decodeAll([Buffer.concat([header, body])]);
75
+ assert.deepEqual(frames, []);
76
+ assert.match(errors[0].message, /^bad_frame: /);
77
+ });
78
+
79
+ test("the encoder refuses a body above the ceiling before writing anything", () => {
80
+ assert.throws(
81
+ () => encodeFrame({ text: "x".repeat(MAX_FRAME_BYTES + 1) }),
82
+ /frame_too_large/,
83
+ );
84
+ });
85
+
86
+ test("the Rust encoder's own vectors decode through this decoder", { skip: !hasVectors }, () => {
87
+ const names = ["adapter_plugin_hello.json", "adapter_plugin_report.json", "adapter_host_assign.json"];
88
+ for (const name of names) {
89
+ const entry = vector(name);
90
+ assert.equal(entry.encoding, "u32_be_length_prefix_plus_utf8_json");
91
+ const body = Buffer.from(entry.frame, "utf8");
92
+ const framed = encodeFrame(JSON.parse(entry.frame));
93
+ assert.equal(framed.readUInt32BE(0), body.length, `${name}: length prefix must be the body byte count`);
94
+ assert.deepEqual(decodeFrame(framed), JSON.parse(entry.frame), `${name}: round trip`);
95
+ assert.deepEqual(decodeAll([framed]).frames, [JSON.parse(entry.frame)], `${name}: streamed decode`);
96
+ }
97
+ });
package/src/index.ts ADDED
@@ -0,0 +1,300 @@
1
+ // pi-onlyne — the onlyne agent adapter for pi.
2
+ //
3
+ // The factory registers event handlers and starts nothing: pi loads extensions
4
+ // in invocations that never open a session, and this plugin must stay inert
5
+ // outside an onlyne-spawned session anyway. The session identity arrives in the
6
+ // environment (`ONLYNE_ROLE`, `ONLYNE_SESSION_ID`, `ONLYNE_TASK_ID`, injected by
7
+ // `crates/onlyne-client/src/dispatch.rs`); with any of the three missing this is
8
+ // a plain pi session and the extension stays silent rather than failing.
9
+ //
10
+ // session_start -> read env + .pi/onlyne.json, connect, register tools
11
+ // turn_start -> heartbeat{running}
12
+ // turn_end -> heartbeat{idle}
13
+ // message_end -> keep the last assistant text; a failed turn is `failed`
14
+ // agent_settled -> completion exit: done|failed
15
+ // session_shutdown -> detach{reason}
16
+
17
+ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { Type } from "typebox";
19
+
20
+ import { OnlyneAgent } from "./agent.mjs";
21
+ import { loadConfig, sessionIdentity } from "./config.mjs";
22
+ import { loadRelay, relayEnabled } from "./relay.mjs";
23
+ import { createSurface } from "./pi-surface.mjs";
24
+
25
+ /**
26
+ * Node's process globals, declared here so the package needs no `@types/node`
27
+ * and no unchecked cast to reach them.
28
+ */
29
+ declare const process: {
30
+ env: Record<string, string | undefined>;
31
+ pid: number;
32
+ stderr: { write(chunk: string): void };
33
+ };
34
+
35
+ /** Socket every role workspace serves; `crates/onlyne-client/src/adapter_socket.rs`. */
36
+ const SOCKET_RELATIVE_PATH = ".onlyne/run/s";
37
+
38
+ /** One image part handed to the pi message surface. */
39
+ interface ImagePartInput {
40
+ mime: string;
41
+ data: string;
42
+ name?: string | null;
43
+ }
44
+
45
+ /** What a `welcome` carries, as the surface needs it. */
46
+ interface WelcomeLike {
47
+ role: string;
48
+ }
49
+
50
+ /**
51
+ * The effect surface the agent drives (`pi-surface.mjs`): the contract between
52
+ * the protocol core and pi's API.
53
+ */
54
+ interface PiSurface {
55
+ available: {
56
+ wakeUser: boolean;
57
+ proseContext: boolean;
58
+ customEntry: boolean;
59
+ status: boolean;
60
+ exit: boolean;
61
+ isIdle: boolean;
62
+ registerTool: boolean;
63
+ registerCommand: boolean;
64
+ };
65
+ wakeUser(text: string, parts?: ImagePartInput[]): boolean;
66
+ proseContext(text: string, welcome: WelcomeLike): boolean;
67
+ customEntry(customType: string, data: unknown): boolean;
68
+ status(text: string): void;
69
+ welcome(welcome: WelcomeLike): void;
70
+ isIdle(): boolean;
71
+ exit(reason: string): void;
72
+ }
73
+
74
+ /**
75
+ * Last assistant text inside one message.
76
+ *
77
+ * Read field by field rather than by type assertion: pi's `AgentMessage` union
78
+ * also admits extension-defined custom messages, so nothing about the shape is
79
+ * guaranteed until `role` says `assistant`.
80
+ */
81
+ function assistantTextOf(message: unknown): string {
82
+ if (!message || typeof message !== "object") return "";
83
+ if (!("role" in message) || message.role !== "assistant") return "";
84
+ if (!("content" in message)) return "";
85
+ const content = message.content;
86
+ if (typeof content === "string") return content;
87
+ if (!Array.isArray(content)) return "";
88
+ const parts: string[] = [];
89
+ for (const part of content) {
90
+ if (!part || typeof part !== "object") continue;
91
+ if (!("type" in part) || part.type !== "text") continue;
92
+ if (!("text" in part) || typeof part.text !== "string") continue;
93
+ parts.push(part.text);
94
+ }
95
+ return parts.join("\n");
96
+ }
97
+
98
+ /** One line of a failed assistant message, when it carries one. */
99
+ function failureOf(message: unknown): string | null {
100
+ if (!message || typeof message !== "object") return null;
101
+ if (!("stopReason" in message) || message.stopReason !== "error") return null;
102
+ if ("errorMessage" in message && typeof message.errorMessage === "string" && message.errorMessage) {
103
+ return message.errorMessage;
104
+ }
105
+ return "turn failed";
106
+ }
107
+
108
+ /**
109
+ * Result shape every tool returns to pi. A named helper because three call
110
+ * sites must produce the identical envelope.
111
+ */
112
+ const textResult = (text: string, details?: unknown) => ({
113
+ content: [{ type: "text" as const, text }],
114
+ details,
115
+ });
116
+
117
+ export default function onlyne(pi: ExtensionAPI) {
118
+ const env: Record<string, string | undefined> = typeof process === "undefined" ? {} : process.env;
119
+ let context: ExtensionContext | null = null;
120
+ let agent: OnlyneAgent | null = null;
121
+ let surface: PiSurface | null = null;
122
+ let registered = false;
123
+
124
+ const log = (line: string, data?: unknown) => {
125
+ const detail = data === undefined ? "" : ` ${JSON.stringify(data)}`;
126
+ process?.stderr?.write(`[pi-onlyne] ${line}${detail}\n`);
127
+ };
128
+
129
+ /** Tools and the `/onlyne` command exist only inside an onlyne session. */
130
+ const registerSurface = () => {
131
+ if (registered || !surface) return;
132
+ registered = true;
133
+ try {
134
+ pi.registerTool(defineTool({
135
+ name: "onlyne_send",
136
+ label: "Onlyne send",
137
+ description:
138
+ "Send one message to another role in this onlyne cluster. kind=note (default) is free text; kind=task hands work to the role and creates a session for it.",
139
+ promptSnippet: "Send a note or a task to another onlyne role",
140
+ promptGuidelines: [
141
+ "Use onlyne_send when a task needs another onlyne role's work; it submits the envelope to the cluster and returns once the router has queued it.",
142
+ ],
143
+ parameters: Type.Object({
144
+ to: Type.String({ description: "target role name, e.g. builder" }),
145
+ text: Type.String({ description: "message body" }),
146
+ kind: Type.Optional(Type.String({ description: '"note" (default) or "task"' })),
147
+ image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
148
+ }),
149
+ async execute(_toolCallId, params) {
150
+ if (!agent) throw new Error("onlyne: session is not connected");
151
+ const result = await agent.sendFromTool({
152
+ to: params.to,
153
+ text: params.text,
154
+ kind: params.kind,
155
+ imagePath: params.image ?? null,
156
+ });
157
+ return textResult(`queued ${result.kind} to ${result.to}`, result);
158
+ },
159
+ }));
160
+ } catch (error) {
161
+ log(`registerTool(onlyne_send) refused: ${error instanceof Error ? error.message : String(error)}`);
162
+ }
163
+ try {
164
+ pi.registerTool(defineTool({
165
+ name: "onlyne_complete",
166
+ label: "Onlyne complete",
167
+ description:
168
+ "End this onlyne task with an explicit outcome. Call it once, when the assigned work is finished (outcome=done), provably impossible (outcome=failed), or withdrawn (outcome=cancelled). Without this call the session still completes on its own: done, or failed when the turn errored. In a workspace whose relay policy (relay.toml) names the handoffs this session owes, the call is refused until each one has gone out.",
169
+ promptSnippet: "Finish the current onlyne task with an outcome and a one-line summary",
170
+ promptGuidelines: [
171
+ "Use onlyne_complete at the end of an onlyne task, naming the outcome and the result in one line; the summary becomes the ledger head.",
172
+ "If onlyne_complete answers 'relay guard', the session still owes a downstream handoff: make it with onlyne_send and call onlyne_complete again. Close the session anyway only when the handoff is genuinely impossible, with force: true and a reason.",
173
+ ],
174
+ parameters: Type.Object({
175
+ outcome: Type.Optional(Type.String({ description: '"done" (default), "failed", or "cancelled"' })),
176
+ text: Type.Optional(Type.String({ description: "one-line result summary" })),
177
+ force: Type.Optional(Type.Boolean({ description: "waive the relay guard; requires a non-empty reason" })),
178
+ reason: Type.Optional(Type.String({ description: "why the relay guard is waived; stamped into the ledger head after `relay-guard-forced: `" })),
179
+ }),
180
+ async execute(_toolCallId, params) {
181
+ if (!agent) throw new Error("onlyne: session is not connected");
182
+ // The exit is not a tool-result flag: pi 0.85.1 has no tool-result
183
+ // `terminate` handling. `agent.complete` asks the surface to shut the
184
+ // process down once the client has acknowledged the report. A relay
185
+ // refusal throws out of here as a tool error, which leaves the session
186
+ // mounted for the handoff that clears it.
187
+ const result = await agent.completeFromTool({
188
+ outcome: params.outcome,
189
+ text: params.text,
190
+ force: params.force,
191
+ reason: params.reason,
192
+ });
193
+ return textResult(`onlyne task ${result.taskId} -> ${result.outcome}`, result);
194
+ },
195
+ }));
196
+ } catch (error) {
197
+ log(`registerTool(onlyne_complete) refused: ${error instanceof Error ? error.message : String(error)}`);
198
+ }
199
+ try {
200
+ pi.registerCommand("onlyne", {
201
+ description: "Onlyne session status: connection, task, reports",
202
+ handler: async (argLine, commandContext) => {
203
+ if (!agent) {
204
+ commandContext.ui.notify("onlyne: no session (ONLYNE_* env is absent)", "info");
205
+ return;
206
+ }
207
+ const verb = String(argLine ?? "").trim();
208
+ if (verb === "disconnect") {
209
+ agent.stop("operator");
210
+ commandContext.ui.notify("onlyne: detached", "info");
211
+ return;
212
+ }
213
+ if (verb === "connect") {
214
+ agent.start();
215
+ commandContext.ui.notify(`onlyne: connecting to ${agent.status().socket}`, "info");
216
+ return;
217
+ }
218
+ commandContext.ui.notify(`onlyne ${JSON.stringify(agent.status())}`, "info");
219
+ },
220
+ });
221
+ } catch (error) {
222
+ log(`registerCommand refused: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+ };
225
+
226
+ pi.on("session_start", async (_event, ctx) => {
227
+ context = ctx;
228
+ const identity = sessionIdentity(env);
229
+ if (!identity) return; // not an onlyne session: stay out of the way
230
+ const config = loadConfig(ctx.cwd);
231
+ if (config.warning) log(config.warning);
232
+ if (!config.enabled) {
233
+ log(`disabled by ${config.path}`);
234
+ return;
235
+ }
236
+ const socketPath = env.ONLYNE_SOCKET || `${ctx.cwd}/${SOCKET_RELATIVE_PATH}`;
237
+ // The guard's policy comes from the spec through the client's environment;
238
+ // a hand-written `relay.toml` beside the package is the fallback a manual
239
+ // installation still has (relay.mjs).
240
+ const relay = loadRelay();
241
+ if (relay.warning) log(relay.warning);
242
+ if (relayEnabled(relay)) {
243
+ const origin = relay.source === "env" ? "the client's environment" : relay.path;
244
+ log(
245
+ `relay guard from ${origin}: required=${JSON.stringify(relay.required)} count=${relay.count ?? "-"}`,
246
+ );
247
+ }
248
+ surface = createSurface({ pi, log, context: () => context });
249
+ agent = new OnlyneAgent({
250
+ socketPath,
251
+ cwd: ctx.cwd,
252
+ role: identity.role,
253
+ sessionId: identity.sessionId,
254
+ taskId: identity.taskId,
255
+ surface,
256
+ relay,
257
+ log,
258
+ });
259
+ log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);
260
+ registerSurface();
261
+ if (!surface.available.wakeUser) {
262
+ // Without an injection surface the payload travels through config_get,
263
+ // and the hello capability list has to say so.
264
+ log("no wakeUser surface: dropping the inject capability");
265
+ agent.capabilities = agent.capabilities.filter((name) => name !== "inject");
266
+ }
267
+ if (config.autoStart) agent.start();
268
+ });
269
+
270
+ pi.on("turn_start", async () => {
271
+ agent?.onTurnStart();
272
+ });
273
+
274
+ pi.on("turn_end", async (event) => {
275
+ const failure = failureOf(event.message);
276
+ if (failure) agent?.onTurnError(failure);
277
+ else agent?.onTurnEnd();
278
+ });
279
+
280
+ pi.on("message_end", async (event) => {
281
+ const failure = failureOf(event.message);
282
+ if (failure) {
283
+ agent?.onTurnError(failure);
284
+ return;
285
+ }
286
+ agent?.noteAssistantText(assistantTextOf(event.message));
287
+ });
288
+
289
+ pi.on("agent_settled", async () => {
290
+ agent?.onSettled();
291
+ });
292
+
293
+ pi.on("session_shutdown", async (event) => {
294
+ agent?.stop(`pi:${event.reason ?? "quit"}`);
295
+ agent = null;
296
+ surface = null;
297
+ registered = false;
298
+ context = null;
299
+ });
300
+ }