@relaymessenger/pi 0.1.4-staging.39 → 0.1.4-staging.40
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/dist/index.d.ts +54 -1
- package/dist/index.js +67 -5
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
|
-
import Relay, { type MessagePart, type PaymentRequestCreateParams } from "@relaymessenger/sdk";
|
|
1
|
+
import Relay, { type MessagePart, type PaymentRequestCreateParams, type RelayWebhookEvent } from "@relaymessenger/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* A dialog one of the person's own Pi extensions opened with `ctx.ui.select`
|
|
4
|
+
* or `ctx.ui.confirm`, the way Pi asks before a tool runs: Pi has "No
|
|
5
|
+
* permission popups" and leaves confirmation flows to extensions (Pi README;
|
|
6
|
+
* examples/extensions/permission-gate.ts). In RPC mode each one arrives as an
|
|
7
|
+
* `extension_ui_request` and waits for an `extension_ui_response` (Pi
|
|
8
|
+
* docs/rpc.md, "Extension UI Protocol").
|
|
9
|
+
*/
|
|
10
|
+
export interface PiDialog {
|
|
11
|
+
readonly method: "select" | "confirm";
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly message?: string;
|
|
14
|
+
/** What the person picks from: the select's options, or Yes and No for a confirm. */
|
|
15
|
+
readonly options: readonly string[];
|
|
16
|
+
/** Pi's own wait: "the agent-side will auto-resolve with a default value when the timeout expires". */
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
/** Who answers Pi's dialogs, and reads the taps that answer them. */
|
|
21
|
+
export interface PiApprovals {
|
|
22
|
+
/** The option picked, or undefined for no answer. */
|
|
23
|
+
dialog(request: PiDialog): Promise<string | undefined>;
|
|
24
|
+
/** Whether this event answered a dialog, so it starts no turn. */
|
|
25
|
+
take(event: RelayWebhookEvent): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
/** A confirm's two options. */
|
|
28
|
+
export declare const CONFIRM_OPTIONS: readonly ["Yes", "No"];
|
|
2
29
|
export interface PiChannelOptions {
|
|
3
30
|
readonly agentToken: string;
|
|
4
31
|
readonly baseURL?: string;
|
|
@@ -7,6 +34,12 @@ export interface PiChannelOptions {
|
|
|
7
34
|
readonly rpcTimeoutMs?: number;
|
|
8
35
|
readonly spawnPi?: (command: string, args: readonly string[], chatId: string) => PiProcess;
|
|
9
36
|
readonly relay?: Relay;
|
|
37
|
+
/**
|
|
38
|
+
* Answers the person's own extensions' dialogs. Without it, a dialog is
|
|
39
|
+
* dismissed at once (`cancelled: true`), so the extension gets its own
|
|
40
|
+
* "no answer" and the turn goes on.
|
|
41
|
+
*/
|
|
42
|
+
readonly approvals?: PiApprovals;
|
|
10
43
|
}
|
|
11
44
|
export interface PiProcess {
|
|
12
45
|
readonly stdin: {
|
|
@@ -16,6 +49,25 @@ export interface PiProcess {
|
|
|
16
49
|
readonly stdout: AsyncIterable<string>;
|
|
17
50
|
readonly kill: () => void;
|
|
18
51
|
}
|
|
52
|
+
interface RpcRecord {
|
|
53
|
+
readonly type?: string;
|
|
54
|
+
readonly id?: string;
|
|
55
|
+
readonly success?: boolean;
|
|
56
|
+
readonly data?: {
|
|
57
|
+
text?: string | null;
|
|
58
|
+
};
|
|
59
|
+
readonly error?: string;
|
|
60
|
+
/** `extension_ui_request` fields (Pi docs/rpc.md). */
|
|
61
|
+
readonly method?: string;
|
|
62
|
+
readonly title?: string;
|
|
63
|
+
readonly message?: string;
|
|
64
|
+
readonly options?: unknown;
|
|
65
|
+
readonly timeout?: unknown;
|
|
66
|
+
}
|
|
67
|
+
/** The `extension_ui_response` to one dialog request, from the option picked. */
|
|
68
|
+
export declare const dialogResponse: (record: Pick<RpcRecord, "id" | "method">, picked: string | undefined) => Record<string, unknown>;
|
|
69
|
+
/** A dialog request as `PiDialog`, or undefined for a method a card cannot answer (input, editor, fire-and-forget). */
|
|
70
|
+
export declare const piDialog: (record: RpcRecord) => Omit<PiDialog, "signal"> | undefined;
|
|
19
71
|
/**
|
|
20
72
|
* The prompt pi is given for one message: the words, then how to answer.
|
|
21
73
|
* This process sends pi's final text for it, and the same buttons and link
|
|
@@ -40,3 +92,4 @@ export declare class PiChannel {
|
|
|
40
92
|
stop(): void;
|
|
41
93
|
}
|
|
42
94
|
export declare const runPiChannel: (options: PiChannelOptions, signal?: AbortSignal) => Promise<void>;
|
|
95
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import Relay, { BUTTONS_BLOCK_INSTRUCTION, BUTTONS_GUIDANCE, PAYMENT_BLOCK_INSTRUCTION, PAYMENT_GUIDANCE, selectionReply, selectionReplyContext, SELECTION_GUIDANCE, SELECTION_BLOCK_INSTRUCTION, LINK_LINE_INSTRUCTION, answerMessages as splitAnswer, createPaymentPart, RelayAPIError, } from "@relaymessenger/sdk";
|
|
4
|
+
/** A confirm's two options. */
|
|
5
|
+
export const CONFIRM_OPTIONS = ["Yes", "No"];
|
|
6
|
+
/** The `extension_ui_response` to one dialog request, from the option picked. */
|
|
7
|
+
export const dialogResponse = (record, picked) => {
|
|
8
|
+
if (picked === undefined)
|
|
9
|
+
return { type: "extension_ui_response", id: record.id, cancelled: true };
|
|
10
|
+
if (record.method === "confirm")
|
|
11
|
+
return { type: "extension_ui_response", id: record.id, confirmed: picked === CONFIRM_OPTIONS[0] };
|
|
12
|
+
return { type: "extension_ui_response", id: record.id, value: picked };
|
|
13
|
+
};
|
|
14
|
+
/** A dialog request as `PiDialog`, or undefined for a method a card cannot answer (input, editor, fire-and-forget). */
|
|
15
|
+
export const piDialog = (record) => {
|
|
16
|
+
if (record.type !== "extension_ui_request" || typeof record.id !== "string")
|
|
17
|
+
return undefined;
|
|
18
|
+
const timeoutMs = typeof record.timeout === "number" && record.timeout > 0 ? record.timeout : undefined;
|
|
19
|
+
const base = { title: typeof record.title === "string" ? record.title : "", ...(typeof record.message === "string" ? { message: record.message } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
|
|
20
|
+
if (record.method === "confirm")
|
|
21
|
+
return { method: "confirm", options: CONFIRM_OPTIONS, ...base };
|
|
22
|
+
if (record.method === "select" && Array.isArray(record.options) && record.options.every((option) => typeof option === "string") && record.options.length) {
|
|
23
|
+
return { method: "select", options: record.options, ...base };
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
27
|
+
/** The dialog methods that wait for an answer (Pi docs/rpc.md). */
|
|
28
|
+
const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
4
29
|
class ChildPiProcess {
|
|
5
30
|
#child;
|
|
6
31
|
constructor(command, args) { this.#child = spawn(command, [...args], { stdio: ["pipe", "pipe", "pipe"] }); this.#child.stderr.resume(); }
|
|
@@ -59,7 +84,34 @@ class ChatSession {
|
|
|
59
84
|
lines;
|
|
60
85
|
settled = false;
|
|
61
86
|
nextId = 0;
|
|
62
|
-
|
|
87
|
+
/** Dialogs waiting on a person; Pi is silent meanwhile, which is not a stall. */
|
|
88
|
+
dialogs = 0;
|
|
89
|
+
#approvals;
|
|
90
|
+
#stop = new AbortController();
|
|
91
|
+
constructor(process, approvals) {
|
|
92
|
+
this.process = process;
|
|
93
|
+
this.lines = process.stdout[Symbol.asyncIterator]();
|
|
94
|
+
this.#approvals = approvals;
|
|
95
|
+
}
|
|
96
|
+
/** Answers one dialog without holding up the reading of Pi's output. */
|
|
97
|
+
#answer(record) {
|
|
98
|
+
const dialog = piDialog(record);
|
|
99
|
+
const reply = (picked) => {
|
|
100
|
+
try {
|
|
101
|
+
this.process.stdin.write(`${JSON.stringify(dialogResponse(record, picked))}\n`);
|
|
102
|
+
}
|
|
103
|
+
catch { /* Pi is gone. */ }
|
|
104
|
+
};
|
|
105
|
+
if (!dialog || !this.#approvals) {
|
|
106
|
+
reply(undefined);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.dialogs += 1;
|
|
110
|
+
void this.#approvals.dialog({ ...dialog, signal: this.#stop.signal })
|
|
111
|
+
.catch(() => undefined)
|
|
112
|
+
.then((picked) => { reply(picked); })
|
|
113
|
+
.finally(() => { this.dialogs -= 1; });
|
|
114
|
+
}
|
|
63
115
|
async read(timeoutMs, signal) {
|
|
64
116
|
if (signal?.aborted)
|
|
65
117
|
throw new Error("Pi RPC request aborted");
|
|
@@ -69,7 +121,14 @@ class ChatSession {
|
|
|
69
121
|
const result = await Promise.race([
|
|
70
122
|
this.lines.next(),
|
|
71
123
|
new Promise((_, reject) => {
|
|
72
|
-
|
|
124
|
+
const expire = () => {
|
|
125
|
+
if (this.dialogs > 0) {
|
|
126
|
+
timer = setTimeout(expire, timeoutMs);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
reject(new Error("Pi RPC request timed out"));
|
|
130
|
+
};
|
|
131
|
+
timer = setTimeout(expire, timeoutMs);
|
|
73
132
|
onAbort = () => reject(new Error("Pi RPC request aborted"));
|
|
74
133
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
75
134
|
}),
|
|
@@ -79,6 +138,8 @@ class ChatSession {
|
|
|
79
138
|
const record = JSON.parse(result.value);
|
|
80
139
|
if (record.type === "agent_settled")
|
|
81
140
|
this.settled = true;
|
|
141
|
+
if (record.type === "extension_ui_request" && DIALOG_METHODS.has(String(record.method)))
|
|
142
|
+
this.#answer(record);
|
|
82
143
|
return record;
|
|
83
144
|
}
|
|
84
145
|
finally {
|
|
@@ -100,7 +161,7 @@ class ChatSession {
|
|
|
100
161
|
}
|
|
101
162
|
}
|
|
102
163
|
}
|
|
103
|
-
stop() { this.process.stdin.end(); this.process.kill(); }
|
|
164
|
+
stop() { this.#stop.abort(); this.process.stdin.end(); this.process.kill(); }
|
|
104
165
|
}
|
|
105
166
|
export class PiChannel {
|
|
106
167
|
#relay;
|
|
@@ -122,7 +183,8 @@ export class PiChannel {
|
|
|
122
183
|
this.#abortListener = () => this.stop();
|
|
123
184
|
signal?.addEventListener("abort", this.#abortListener, { once: true });
|
|
124
185
|
try {
|
|
125
|
-
await this.#relay.websocket.run({ ...(signal ? { signal } : {}), onEvent: async (event) =>
|
|
186
|
+
await this.#relay.websocket.run({ ...(signal ? { signal } : {}), onEvent: async (event) => { if (await this.#options.approvals?.take(event))
|
|
187
|
+
return; await this.#handle(event, signal); }, onFullSync: async () => { throw new Error("Pi channel cannot acknowledge FULL sync without a durable Relay inbox"); } });
|
|
126
188
|
}
|
|
127
189
|
finally {
|
|
128
190
|
this.stop();
|
|
@@ -163,7 +225,7 @@ export class PiChannel {
|
|
|
163
225
|
const data = event.data;
|
|
164
226
|
let session = this.#sessions.get(data.chat.id);
|
|
165
227
|
if (!session) {
|
|
166
|
-
session = new ChatSession(this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", ...(this.#options.piArgs ?? [])], data.chat.id));
|
|
228
|
+
session = new ChatSession(this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", ...(this.#options.piArgs ?? [])], data.chat.id), this.#options.approvals);
|
|
167
229
|
this.#sessions.set(data.chat.id, session);
|
|
168
230
|
}
|
|
169
231
|
const timeout = this.#options.rpcTimeoutMs ?? 60_000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relaymessenger/pi",
|
|
3
|
-
"version": "0.1.4-staging.
|
|
3
|
+
"version": "0.1.4-staging.40",
|
|
4
4
|
"description": "Relay Messenger channel for Pi over Relay v1 WebSocket delivery.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test": "vitest run"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@relaymessenger/sdk": "0.3.6-staging.
|
|
37
|
+
"@relaymessenger/sdk": "0.3.6-staging.44"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@earendil-works/pi-coding-agent": "0.86.0",
|