@relaymessenger/pi 0.1.0-staging.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,3 +6,12 @@ each inbound Message through Pi RPC mode, and sends one final text reply.
6
6
  `relay connect pi` configures and runs this path for you. For a direct
7
7
  integration, pass `agentToken`, optionally `baseURL` and `piCommand`, then use
8
8
  `runPiChannel()`.
9
+
10
+ To load the native Pi extension in an existing Pi installation:
11
+
12
+ ```bash
13
+ pi install npm:@relaymessenger/pi
14
+ ```
15
+
16
+ Set `RELAY_AGENT_TOKEN` before using `/relay-connect`. Set
17
+ `RELAY_BASE_URL` when the Agent belongs to a non-default API environment.
package/dist/index.d.ts CHANGED
@@ -4,7 +4,8 @@ export interface PiChannelOptions {
4
4
  readonly baseURL?: string;
5
5
  readonly piCommand?: string;
6
6
  readonly piArgs?: readonly string[];
7
- readonly spawnPi?: (command: string, args: readonly string[]) => PiProcess;
7
+ readonly rpcTimeoutMs?: number;
8
+ readonly spawnPi?: (command: string, args: readonly string[], chatId: string) => PiProcess;
8
9
  readonly relay?: Relay;
9
10
  }
10
11
  export interface PiProcess {
@@ -19,5 +20,6 @@ export declare class PiChannel {
19
20
  #private;
20
21
  constructor(options: PiChannelOptions);
21
22
  run(signal?: AbortSignal): Promise<void>;
23
+ stop(): void;
22
24
  }
23
25
  export declare const runPiChannel: (options: PiChannelOptions, signal?: AbortSignal) => Promise<void>;
package/dist/index.js CHANGED
@@ -3,10 +3,7 @@ import { createInterface } from "node:readline";
3
3
  import Relay, {} from "@relaymessenger/sdk";
4
4
  class ChildPiProcess {
5
5
  #child;
6
- constructor(command, args) {
7
- this.#child = spawn(command, [...args], { stdio: ["pipe", "pipe", "pipe"] });
8
- this.#child.stderr.resume();
9
- }
6
+ constructor(command, args) { this.#child = spawn(command, [...args], { stdio: ["pipe", "pipe", "pipe"] }); this.#child.stderr.resume(); }
10
7
  get stdin() { return this.#child.stdin; }
11
8
  get stdout() { return createInterface({ input: this.#child.stdout }); }
12
9
  kill = () => { this.#child.kill(); };
@@ -16,77 +13,127 @@ const textFromEvent = (event) => {
16
13
  return null;
17
14
  return event.data.parts.filter((part) => part.type === "text" || part.type === "link").map((part) => part.value).join("\n").trim() || null;
18
15
  };
16
+ class ChatSession {
17
+ process;
18
+ lines;
19
+ settled = false;
20
+ nextId = 0;
21
+ constructor(process) { this.process = process; this.lines = process.stdout[Symbol.asyncIterator](); }
22
+ async read(timeoutMs, signal) {
23
+ if (signal?.aborted)
24
+ throw new Error("Pi RPC request aborted");
25
+ let timer;
26
+ let onAbort;
27
+ try {
28
+ const result = await Promise.race([
29
+ this.lines.next(),
30
+ new Promise((_, reject) => {
31
+ timer = setTimeout(() => reject(new Error("Pi RPC request timed out")), timeoutMs);
32
+ onAbort = () => reject(new Error("Pi RPC request aborted"));
33
+ signal?.addEventListener("abort", onAbort, { once: true });
34
+ }),
35
+ ]);
36
+ if (result.done)
37
+ throw new Error("Pi RPC process exited");
38
+ const record = JSON.parse(result.value);
39
+ if (record.type === "agent_settled")
40
+ this.settled = true;
41
+ return record;
42
+ }
43
+ finally {
44
+ if (timer)
45
+ clearTimeout(timer);
46
+ if (onAbort)
47
+ signal?.removeEventListener("abort", onAbort);
48
+ }
49
+ }
50
+ async command(type, data, timeoutMs, signal) {
51
+ const id = String(++this.nextId);
52
+ this.process.stdin.write(`${JSON.stringify({ id, type, ...data })}\n`);
53
+ for (;;) {
54
+ const record = await this.read(timeoutMs, signal);
55
+ if (record.type === "response" && record.id === id) {
56
+ if (record.success === false)
57
+ throw new Error(record.error ?? `Pi RPC ${type} failed`);
58
+ return record;
59
+ }
60
+ }
61
+ }
62
+ stop() { this.process.stdin.end(); this.process.kill(); }
63
+ }
19
64
  export class PiChannel {
20
65
  #relay;
21
66
  #options;
22
67
  #spawnPi;
68
+ #sessions = new Map();
69
+ #turns = new Map();
70
+ #seen = new Set();
71
+ #inflight = new Map();
72
+ #abortListener;
23
73
  constructor(options) {
24
74
  if (!options.agentToken.trim())
25
75
  throw new Error("Relay Agent Token is required");
26
76
  this.#options = options;
27
- this.#relay = options.relay ?? new Relay({
28
- apiKey: options.agentToken,
29
- ...(options.baseURL ? { baseURL: options.baseURL } : {}),
30
- });
77
+ this.#relay = options.relay ?? new Relay({ apiKey: options.agentToken, ...(options.baseURL ? { baseURL: options.baseURL } : {}) });
31
78
  this.#spawnPi = options.spawnPi ?? ((command, args) => new ChildPiProcess(command, args));
32
79
  }
33
80
  async run(signal) {
34
- await this.#relay.websocket.run({
35
- ...(signal ? { signal } : {}),
36
- onEvent: async (event) => { await this.#handle(event); },
37
- onFullSync: async () => { },
38
- });
81
+ this.#abortListener = () => this.stop();
82
+ signal?.addEventListener("abort", this.#abortListener, { once: true });
83
+ try {
84
+ await this.#relay.websocket.run({ ...(signal ? { signal } : {}), onEvent: async (event) => this.#handle(event, signal), onFullSync: async () => { throw new Error("Pi channel cannot acknowledge FULL sync without a durable Relay inbox"); } });
85
+ }
86
+ finally {
87
+ this.stop();
88
+ if (signal)
89
+ signal.removeEventListener("abort", this.#abortListener);
90
+ }
39
91
  }
40
- async #handle(event) {
92
+ stop() { for (const session of this.#sessions.values())
93
+ session.stop(); this.#sessions.clear(); }
94
+ async #handle(event, signal) {
95
+ if (this.#seen.has(event.event_id))
96
+ return;
41
97
  const message = textFromEvent(event);
42
98
  if (!message)
43
99
  return;
44
- const pi = this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", ...(this.#options.piArgs ?? [])]);
100
+ const data = event.data;
101
+ this.#seen.add(event.event_id);
102
+ const prior = this.#turns.get(data.chat.id) ?? Promise.resolve();
103
+ const turn = prior.then(() => this.#runTurn(event, message, signal));
104
+ this.#turns.set(data.chat.id, turn.catch(() => undefined));
105
+ this.#inflight.set(event.event_id, turn);
45
106
  try {
46
- const lines = pi.stdout[Symbol.asyncIterator]();
47
- let id = 0;
48
- const readResponse = async (requestId, command) => {
49
- for (;;) {
50
- const item = await lines.next();
51
- if (item.done)
52
- throw new Error("Pi RPC process exited before responding");
53
- const record = JSON.parse(item.value);
54
- if (record.type === "response" && record.id === requestId) {
55
- if (record.success === false)
56
- throw new Error(record.error ?? `Pi RPC ${command} failed`);
57
- return record;
58
- }
59
- }
60
- };
61
- const promptId = String(++id);
62
- pi.stdin.write(JSON.stringify({ id: promptId, type: "prompt", message }) + "\n");
63
- await readResponse(promptId, "prompt");
64
- let settled = false;
65
- while (!settled) {
66
- const item = await lines.next();
67
- if (item.done)
68
- throw new Error("Pi RPC process exited before settling");
69
- const record = JSON.parse(item.value);
70
- settled = record.type === "agent_settled";
71
- }
72
- const textId = String(++id);
73
- pi.stdin.write(JSON.stringify({ id: textId, type: "get_last_assistant_text" }) + "\n");
74
- const response = await readResponse(textId, "get_last_assistant_text");
75
- const answer = response.data?.text?.trim();
76
- if (!answer)
77
- throw new Error("Pi returned no final text answer");
78
- const data = event.data;
79
- await this.#relay.chats.messages.send(data.chat.id, {
80
- message: {
81
- parts: [{ type: "text", value: answer }],
82
- idempotency_key: `pi-${event.event_id}`,
83
- },
84
- });
107
+ await turn;
108
+ }
109
+ catch (error) {
110
+ this.#seen.delete(event.event_id);
111
+ throw error;
85
112
  }
86
113
  finally {
87
- pi.stdin.end();
88
- pi.kill();
114
+ this.#inflight.delete(event.event_id);
115
+ }
116
+ }
117
+ async #runTurn(event, message, signal) {
118
+ const data = event.data;
119
+ let session = this.#sessions.get(data.chat.id);
120
+ if (!session) {
121
+ session = new ChatSession(this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", ...(this.#options.piArgs ?? [])], data.chat.id));
122
+ this.#sessions.set(data.chat.id, session);
123
+ }
124
+ const timeout = this.#options.rpcTimeoutMs ?? 60_000;
125
+ await session.command("prompt", { message }, timeout, signal);
126
+ if (!session.settled) {
127
+ while (!session.settled)
128
+ await session.read(timeout, signal);
89
129
  }
130
+ const response = await session.command("get_last_assistant_text", {}, timeout, signal);
131
+ const answer = response.data?.text?.trim();
132
+ if (!answer)
133
+ throw new Error("Pi returned no final text answer");
134
+ const chunks = answer.match(/[\s\S]{1,10000}/gu) ?? [];
135
+ for (const [index, chunk] of chunks.entries())
136
+ await this.#relay.chats.messages.send(data.chat.id, { message: { parts: [{ type: "text", value: chunk }], idempotency_key: `pi-${event.event_id}-${index}` } });
90
137
  }
91
138
  }
92
139
  export const runPiChannel = (options, signal) => new PiChannel(options).run(signal);
@@ -0,0 +1,7 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ /**
3
+ * Pi-native entry point. It deliberately exposes only lifecycle-safe commands;
4
+ * Relay ingress remains owned by runPiChannel so the CLI and extension share
5
+ * the same authenticated routing implementation.
6
+ */
7
+ export default function relayPiExtension(pi: ExtensionAPI): void;
package/dist/native.js ADDED
@@ -0,0 +1,44 @@
1
+ import { runPiChannel } from "./index.js";
2
+ let controller;
3
+ let running;
4
+ /**
5
+ * Pi-native entry point. It deliberately exposes only lifecycle-safe commands;
6
+ * Relay ingress remains owned by runPiChannel so the CLI and extension share
7
+ * the same authenticated routing implementation.
8
+ */
9
+ export default function relayPiExtension(pi) {
10
+ pi.registerCommand("relay-connect", {
11
+ description: "Start the Relay channel using the configured Agent Token",
12
+ handler: async (_args, ctx) => {
13
+ if (running) {
14
+ ctx.ui.notify("Relay Pi channel is already running.", "info");
15
+ return;
16
+ }
17
+ const agentToken = process.env.RELAY_AGENT_TOKEN?.trim();
18
+ if (!agentToken) {
19
+ ctx.ui.notify("RELAY_AGENT_TOKEN is not configured.", "error");
20
+ return;
21
+ }
22
+ controller = new AbortController();
23
+ running = runPiChannel({
24
+ agentToken,
25
+ ...(process.env.RELAY_BASE_URL?.trim()
26
+ ? { baseURL: process.env.RELAY_BASE_URL.trim() }
27
+ : {}),
28
+ }, controller.signal).catch((error) => {
29
+ ctx.ui.notify(`Relay Pi channel stopped: ${error instanceof Error ? error.message : String(error)}`, "error");
30
+ }).finally(() => {
31
+ running = undefined;
32
+ controller = undefined;
33
+ });
34
+ ctx.ui.notify("Relay Pi channel started.", "info");
35
+ },
36
+ });
37
+ pi.registerCommand("relay-disconnect", {
38
+ description: "Stop the Relay channel started by the Relay CLI",
39
+ handler: async (_args, ctx) => {
40
+ controller?.abort();
41
+ ctx.ui.notify("Relay Pi channel stopping.", "info");
42
+ },
43
+ });
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/pi",
3
- "version": "0.1.0-staging.0",
3
+ "version": "0.1.0",
4
4
  "description": "Relay Messenger channel for Pi over Relay v1 WebSocket delivery.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,21 +15,50 @@
15
15
  },
16
16
  "license": "MIT",
17
17
  "packageManager": "npm@12.0.2",
18
- "engines": { "node": ">=22.22.3" },
19
- "files": ["dist/", "README.md", "LICENSE"],
18
+ "engines": {
19
+ "node": ">=22.22.3"
20
+ },
21
+ "files": [
22
+ "dist/",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "pi": {
27
+ "extensions": [
28
+ "./dist/native.js"
29
+ ]
30
+ },
20
31
  "scripts": {
21
32
  "build": "tsc -p tsconfig.build.json",
22
33
  "check": "tsc -p tsconfig.json --noEmit",
23
34
  "test": "vitest run"
24
35
  },
25
- "dependencies": { "@relaymessenger/sdk": "0.3.3-staging.7" },
36
+ "dependencies": {
37
+ "@relaymessenger/sdk": "0.3.3"
38
+ },
26
39
  "devDependencies": {
40
+ "@mariozechner/pi-coding-agent": "0.73.1",
27
41
  "@types/node": "26.0.0",
28
42
  "typescript": "7.0.2",
29
43
  "vitest": "4.1.10"
30
44
  },
31
- "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/", "tag": "staging", "provenance": true },
32
- "repository": { "type": "git", "url": "git+https://github.com/RelayMessenger/Relay-SDK.git", "directory": "packages/pi" },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/",
48
+ "tag": "staging",
49
+ "provenance": true
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/RelayMessenger/Relay-SDK.git",
54
+ "directory": "packages/pi"
55
+ },
33
56
  "homepage": "https://github.com/RelayMessenger/Relay-SDK/tree/main/packages/pi#readme",
34
- "keywords": ["relay", "pi", "channel", "messaging"]
57
+ "keywords": [
58
+ "relay",
59
+ "pi",
60
+ "pi-package",
61
+ "channel",
62
+ "messaging"
63
+ ]
35
64
  }