@relaymessenger/pi 0.1.14 → 0.1.15-staging.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
@@ -64,6 +64,24 @@ its last words to the chat of the last Message, kept in
64
64
  `relay-last-chat.json` in the agent directory so it survives a restart; a turn
65
65
  typed in the session stays there.
66
66
 
67
+ ## Owner approval in session mode
68
+
69
+ With [`@gotgenes/pi-permission-system`](https://www.npmjs.com/package/@gotgenes/pi-permission-system)
70
+ loaded, a gated tool call in a run a Relay Message started is asked of the
71
+ agent's owners with the same card the Relay CLI sends in channel mode:
72
+ **Allow once**, **Allow for session** or **Deny**, in each owner's own chat
73
+ with the agent. Only an owner's tap counts. No answer in 10 minutes denies
74
+ it. Allow for session covers the patterns the permission system suggests
75
+ for the call, until the session ends. A run typed at the app, a path or
76
+ external-directory ask (the chain will not let a link allow those), or an
77
+ agent no owner can be reached for is asked in the app as before.
78
+
79
+ Turn it on by naming the `relay` link in the permission system's config:
80
+
81
+ ```json
82
+ { "authorizerChain": ["relay"] }
83
+ ```
84
+
67
85
  ## With pi-channels
68
86
 
69
87
  [pi-channels](https://github.com/espennilsen/pi/tree/main/packages/pi-channels)
@@ -0,0 +1,194 @@
1
+ import { type MessagePart, type Relay, type RelayWebhookEvent } from "@relaymessenger/sdk";
2
+ import type { PiApprovals } from "./index.js";
3
+ /**
4
+ * Relays a coding agent's own permission prompts to the people who own the
5
+ * Relay agent, as a card in each owner's chat with the agent.
6
+ *
7
+ * The harness decides what needs a person's yes; this process only carries
8
+ * the question and the answer. That is how every integration of a messenger
9
+ * with a coding agent does it: Claude Code's channels relay the harness's
10
+ * permission prompt and let the harness apply the verdict (Claude Code
11
+ * channels reference, "Relay permission prompts"; saved at
12
+ * _sources/approvals-inkbox-20260926/claude-code-channels-reference.md), and
13
+ * Inkbox's Claude Code and Codex plugins forward each `can_use_tool` and
14
+ * `requestApproval` to the human (inkbox-claude-code-plugin-sessions.py.txt
15
+ * `_can_use_tool`; inkbox-codex-plugin-codex_client.py.txt
16
+ * `_handle_server_request`).
17
+ *
18
+ * Only an owner answers. The official Telegram plugin sends the prompt only to
19
+ * allowlisted DMs (claude-plugins-official-telegram-server.ts.txt); the
20
+ * iMessage plugin sends it to the owner's own chat because "that authority is
21
+ * the owner's alone" (claude-plugins-official-imessage-server.ts.txt). Relay
22
+ * names the owners itself: `GET /v1/me` returns `owner_people` for the calling
23
+ * Agent Token (Relay-Server server/src/me.ts, `app.get("/me")`).
24
+ *
25
+ * The card is a `rich_card` whose reply suggestions are the harness's
26
+ * choices; a tap comes back as the owner's own message, a `suggestion_response`
27
+ * carrying the choice's id and replying to the card. A Pi select with more
28
+ * choices than a card holds is a single-choice `selection` instead, answered
29
+ * by a `selection_response`. A tap from anyone else is not registered, and
30
+ * the card stays open for an owner to answer.
31
+ */
32
+ /**
33
+ * How a person may answer, in the words every harness maps to. `choice` is an
34
+ * option of a question that is neither an allow nor a deny (a Pi extension's
35
+ * `select`).
36
+ */
37
+ export type ApprovalDecision = "allow_once" | "allow_session" | "deny" | "choice";
38
+ /** One choice the harness offers, with the label the card shows. */
39
+ export interface ApprovalChoice {
40
+ /** The harness's own id for the choice, handed back to it unchanged. */
41
+ id: string;
42
+ label: string;
43
+ decision: ApprovalDecision;
44
+ }
45
+ /** One permission prompt from a harness. */
46
+ export interface ApprovalRequest {
47
+ /** Who is asking, e.g. "Claude Code". */
48
+ harness: string;
49
+ /** The tool it wants to use, e.g. "Bash". */
50
+ tool: string;
51
+ /** The card's title, one sentence; `<harness> asks to use <tool>.` when absent. */
52
+ title?: string;
53
+ /** The command or file the harness wants to use. */
54
+ summary: string;
55
+ /**
56
+ * A plain line under the command: Bash's own description of it, which
57
+ * Claude Code's terminal prompt shows under the command too.
58
+ */
59
+ note?: string;
60
+ /** The full input as a person reads it. */
61
+ detail: string;
62
+ /**
63
+ * Whether `detail` holds anything `summary` and `note` do not: an input
64
+ * field beyond the summarized value and the note. The card then shows
65
+ * `detail` in place of them.
66
+ */
67
+ extra: boolean;
68
+ /** The harness's own choices, allow first. */
69
+ choices: readonly ApprovalChoice[];
70
+ /**
71
+ * How long to wait for an answer. The harness's own timeout when it sets
72
+ * one (Codex's `autoResolutionMs`); otherwise `APPROVAL_TIMEOUT_MS`.
73
+ */
74
+ timeoutMs?: number;
75
+ /** Stops the wait, for example when the turn is cancelled. */
76
+ signal?: AbortSignal;
77
+ }
78
+ /** How a prompt ended. With no `choice`, the harness is told no. */
79
+ export interface ApprovalOutcome {
80
+ choice?: ApprovalChoice;
81
+ /** The owner who answered. */
82
+ by?: string;
83
+ reason: "answered" | "timeout" | "no_owner" | "unsent" | "aborted";
84
+ }
85
+ /**
86
+ * Inkbox waits 600 seconds for a person's answer and then treats the request
87
+ * as not approved (`permission_timeout_s: float = 600.0`,
88
+ * inkbox-claude-code-plugin-config.py.txt:108 and
89
+ * inkbox-codex-plugin-config.py.txt:132).
90
+ */
91
+ export declare const APPROVAL_TIMEOUT_MS = 600000;
92
+ /** The one line the terminal shows when nobody can be asked. */
93
+ export declare const noOwnerLine: (request: Pick<ApprovalRequest, "harness" | "tool">) => string;
94
+ /** What the reply to the card says once a prompt has ended. */
95
+ export declare const outcomeLine: (outcome: ApprovalOutcome) => string;
96
+ /**
97
+ * The contract's limits (Relay-Server contracts/developer/openapi.yaml), the
98
+ * card's from the SDK; a `SelectionPart` subtitle is 512 characters and an
99
+ * id-bearing `SelectionOption` has a 24-character label and a 200-character id.
100
+ */
101
+ export declare const CARD_LIMITS: {
102
+ readonly title: 200;
103
+ readonly description: 2000;
104
+ readonly suggestions: 4;
105
+ readonly label: 25;
106
+ readonly replyId: 256;
107
+ readonly selectionTitle: 60;
108
+ readonly selectionSubtitle: 512;
109
+ readonly selectionOptions: 25;
110
+ readonly selectionLabel: 24;
111
+ readonly selectionId: 200;
112
+ };
113
+ /** The card's title: the harness's sentence, or `<harness> asks to use <tool>.` */
114
+ export declare const cardTitle: (request: ApprovalRequest) => string;
115
+ /**
116
+ * The card's description, plain text as the app draws it (Relay-iOS
117
+ * RelayRichCardRow.swift, `Text(description)`): the command and its note, or,
118
+ * when the input holds more than them, the whole input as a person reads it.
119
+ * Cut at the contract's 2,000 characters with a closing "…".
120
+ */
121
+ export declare const cardDescription: (request: ApprovalRequest) => string | undefined;
122
+ /**
123
+ * The prefix of every reply id on an approval card. Relay stores a reply
124
+ * suggestion's `id` as sent and returns it in the person's
125
+ * `suggestion_response` (or `selection_response`), so a tap on any approval
126
+ * card, even one a bridge process no longer holds (sent before a restart),
127
+ * is known by its id alone and never becomes a person's turn.
128
+ */
129
+ export declare const APPROVAL_REPLY_PREFIX = "relay-approval:";
130
+ /** Whether a reply id is one an approval card gave out. */
131
+ export declare const isApprovalReplyId: (id: string) => boolean;
132
+ /**
133
+ * The message part that asks: a `rich_card` with one reply suggestion per
134
+ * choice, or, past the card's 4 suggestions, a single-choice `selection` of up
135
+ * to 25 options.
136
+ */
137
+ export declare const approvalPart: (request: ApprovalRequest) => MessagePart;
138
+ /** The choice a reply id names, as `approvalPart` gave it out. */
139
+ export declare const choiceFor: (request: ApprovalRequest, id: string) => ApprovalChoice | undefined;
140
+ /**
141
+ * A tap on a card, as `message.received` carries it: the card message it
142
+ * replies to, and the id of the reply the person chose. A card reply is plain
143
+ * text then `suggestion_response`; a picker answer, plain text then
144
+ * `selection_response` (Relay-Server contracts/developer/openapi.yaml,
145
+ * `SuggestionResponsePart` and `SelectionResponsePart`).
146
+ */
147
+ export declare const readAnswer: (event: RelayWebhookEvent) => {
148
+ messageId: string;
149
+ id: string;
150
+ } | undefined;
151
+ /** The Relay calls this needs: who owns the agent, and the chat with each owner. */
152
+ export type ApprovalClient = Pick<Relay, "chats" | "me">;
153
+ export interface OwnerApprovalsOptions {
154
+ client: ApprovalClient;
155
+ say(line: string): void;
156
+ }
157
+ /**
158
+ * One per bridge process. `ask` sends the card and waits; `take` reads every
159
+ * event first and keeps the answers to its own cards, so they never start a turn.
160
+ */
161
+ export declare class OwnerApprovals {
162
+ #private;
163
+ constructor(options: OwnerApprovalsOptions);
164
+ ask(request: ApprovalRequest): Promise<ApprovalOutcome>;
165
+ /**
166
+ * Whether this event is an answer to one of this process's approval cards.
167
+ * Such an event is handled here and must not start a turn.
168
+ */
169
+ take(event: RelayWebhookEvent): Promise<boolean>;
170
+ }
171
+ /** What a harness is told when a prompt ends without an allow (Inkbox's words, sessions.py.txt:1249). */
172
+ export declare const denialMessage: (outcome: ApprovalOutcome) => string;
173
+ /** One line of a tool input, for the card's summary. */
174
+ export declare const inputSummary: (input: Record<string, unknown>) => string;
175
+ /** Whether an input says anything in a field other than `shown`, the ones the card draws. */
176
+ export declare const beyond: (input: Record<string, unknown>, shown: readonly string[]) => boolean;
177
+ /**
178
+ * A tool input as its card shows it: the summarized value, Bash's own
179
+ * description as the note, the readable detail, and whether the input holds
180
+ * more. An input with no field to summarize is summarized whole.
181
+ */
182
+ export declare const inputCard: (input: Record<string, unknown>) => Pick<ApprovalRequest, "summary" | "note" | "detail" | "extra">;
183
+ /**
184
+ * The whole tool input as a person reads it, for the card's description: an Edit is its
185
+ * file and a diff (a MultiEdit, a diff per edit), a Write its file and the
186
+ * content, anything else one `name: value` line per field.
187
+ */
188
+ export declare const inputDetail: (input: unknown) => string;
189
+ /**
190
+ * Pi's dialogs, answered by the owners. A confirm is Yes or No, an allow and
191
+ * a deny; a select offers the extension's own options, each as it wrote it.
192
+ * The card's first line is the extension's own title.
193
+ */
194
+ export declare const piApprovals: (approvals: Pick<OwnerApprovals, "ask" | "take">) => PiApprovals;
@@ -0,0 +1,424 @@
1
+ import { createHash } from "node:crypto";
2
+ import { RICH_CARD_DESCRIPTION_MAX_LENGTH, RICH_CARD_MAX_SUGGESTIONS, RICH_CARD_TITLE_MAX_LENGTH, SELECTION_MAX_OPTIONS, SELECTION_TITLE_MAX_LENGTH, SUGGESTION_ID_MAX_LENGTH, SUGGESTION_LABEL_MAX_LENGTH, selectionReply, suggestionReply, } from "@relaymessenger/sdk";
3
+ /**
4
+ * Inkbox waits 600 seconds for a person's answer and then treats the request
5
+ * as not approved (`permission_timeout_s: float = 600.0`,
6
+ * inkbox-claude-code-plugin-config.py.txt:108 and
7
+ * inkbox-codex-plugin-config.py.txt:132).
8
+ */
9
+ export const APPROVAL_TIMEOUT_MS = 600_000;
10
+ /** The one line the terminal shows when nobody can be asked. */
11
+ export const noOwnerLine = (request) => `${request.harness} was not allowed to use ${request.tool}: nobody who owns this agent has a Relay app account to approve it. Link a phone from Settings in the Relay console, or with \`relay phone link\`.`;
12
+ /** What the reply to the card says once a prompt has ended. */
13
+ export const outcomeLine = (outcome) => {
14
+ const who = outcome.by ? `@${outcome.by}` : "Someone";
15
+ switch (outcome.choice?.decision) {
16
+ case "allow_once": return `${who} allowed this once.`;
17
+ case "allow_session": return `${who} allowed this for this session.`;
18
+ case "deny": return `${who} denied this.`;
19
+ case "choice": return `${who} chose ${outcome.choice.label}.`;
20
+ default: break;
21
+ }
22
+ if (outcome.reason === "timeout")
23
+ return "Timed out, not approved.";
24
+ return "Not approved.";
25
+ };
26
+ /**
27
+ * The contract's limits (Relay-Server contracts/developer/openapi.yaml), the
28
+ * card's from the SDK; a `SelectionPart` subtitle is 512 characters and an
29
+ * id-bearing `SelectionOption` has a 24-character label and a 200-character id.
30
+ */
31
+ export const CARD_LIMITS = {
32
+ title: RICH_CARD_TITLE_MAX_LENGTH,
33
+ description: RICH_CARD_DESCRIPTION_MAX_LENGTH,
34
+ suggestions: RICH_CARD_MAX_SUGGESTIONS,
35
+ label: SUGGESTION_LABEL_MAX_LENGTH,
36
+ replyId: SUGGESTION_ID_MAX_LENGTH,
37
+ selectionTitle: SELECTION_TITLE_MAX_LENGTH,
38
+ selectionSubtitle: 512,
39
+ selectionOptions: SELECTION_MAX_OPTIONS,
40
+ selectionLabel: 24,
41
+ selectionId: 200,
42
+ };
43
+ const clip = (text, max) => text.length <= max ? text : `${text.slice(0, max - 1)}…`;
44
+ const oneLine = (text) => text.replace(/\s+/gu, " ").trim();
45
+ /** The card's title: the harness's sentence, or `<harness> asks to use <tool>.` */
46
+ export const cardTitle = (request) => oneLine(request.title ?? "") || `${request.harness} asks to use ${request.tool}.`;
47
+ /**
48
+ * The card's description, plain text as the app draws it (Relay-iOS
49
+ * RelayRichCardRow.swift, `Text(description)`): the command and its note, or,
50
+ * when the input holds more than them, the whole input as a person reads it.
51
+ * Cut at the contract's 2,000 characters with a closing "…".
52
+ */
53
+ export const cardDescription = (request) => {
54
+ const text = request.extra
55
+ ? request.detail.trim()
56
+ : [request.summary.trim(), request.note?.trim() ?? ""].filter(Boolean).join("\n\n");
57
+ return text ? clip(text, CARD_LIMITS.description) : undefined;
58
+ };
59
+ /**
60
+ * The prefix of every reply id on an approval card. Relay stores a reply
61
+ * suggestion's `id` as sent and returns it in the person's
62
+ * `suggestion_response` (or `selection_response`), so a tap on any approval
63
+ * card, even one a bridge process no longer holds (sent before a restart),
64
+ * is known by its id alone and never becomes a person's turn.
65
+ */
66
+ export const APPROVAL_REPLY_PREFIX = "relay-approval:";
67
+ /** Whether a reply id is one an approval card gave out. */
68
+ export const isApprovalReplyId = (id) => id.startsWith(APPROVAL_REPLY_PREFIX);
69
+ /**
70
+ * The id a choice's reply carries: the prefix and the harness's own id, or,
71
+ * when that is longer than the contract allows, the prefix and the SHA-256 of
72
+ * the harness's id, which no two different ids share.
73
+ */
74
+ const replyId = (choice, max) => {
75
+ const plain = `${APPROVAL_REPLY_PREFIX}${choice.id}`;
76
+ return plain.length <= max ? plain : `${APPROVAL_REPLY_PREFIX}sha256:${createHash("sha256").update(choice.id).digest("hex")}`;
77
+ };
78
+ /** Whether the choices fit on one card's suggestions. */
79
+ const onCard = (request) => request.choices.length <= CARD_LIMITS.suggestions;
80
+ /**
81
+ * The message part that asks: a `rich_card` with one reply suggestion per
82
+ * choice, or, past the card's 4 suggestions, a single-choice `selection` of up
83
+ * to 25 options.
84
+ */
85
+ export const approvalPart = (request) => {
86
+ if (request.choices.length > CARD_LIMITS.selectionOptions) {
87
+ throw new Error(`${request.harness} offered ${request.choices.length} choices; a Relay approval list holds at most ${CARD_LIMITS.selectionOptions}, so it was not sent.`);
88
+ }
89
+ const ids = request.choices.map((choice) => replyId(choice, onCard(request) ? CARD_LIMITS.replyId : CARD_LIMITS.selectionId));
90
+ if (new Set(ids).size !== ids.length) {
91
+ throw new Error(`${request.harness} offered two choices with the same id; the owner's answer could not say which, so it was not sent.`);
92
+ }
93
+ const description = cardDescription(request);
94
+ if (onCard(request)) {
95
+ return {
96
+ type: "rich_card",
97
+ title: clip(cardTitle(request), CARD_LIMITS.title),
98
+ ...(description !== undefined ? { description } : {}),
99
+ suggestions: request.choices.map((choice, index) => ({
100
+ type: "reply",
101
+ label: clip(oneLine(choice.label) || choice.id, CARD_LIMITS.label),
102
+ id: ids[index],
103
+ })),
104
+ };
105
+ }
106
+ return {
107
+ type: "selection",
108
+ title: clip(cardTitle(request), CARD_LIMITS.selectionTitle),
109
+ ...(description !== undefined ? { subtitle: clip(description, CARD_LIMITS.selectionSubtitle) } : {}),
110
+ multiple: false,
111
+ options: request.choices.map((choice, index) => ({
112
+ id: ids[index],
113
+ label: clip(oneLine(choice.label) || choice.id, CARD_LIMITS.selectionLabel),
114
+ })),
115
+ };
116
+ };
117
+ /** The choice a reply id names, as `approvalPart` gave it out. */
118
+ export const choiceFor = (request, id) => {
119
+ const max = onCard(request) ? CARD_LIMITS.replyId : CARD_LIMITS.selectionId;
120
+ return request.choices.find((choice) => replyId(choice, max) === id);
121
+ };
122
+ /**
123
+ * A tap on a card, as `message.received` carries it: the card message it
124
+ * replies to, and the id of the reply the person chose. A card reply is plain
125
+ * text then `suggestion_response`; a picker answer, plain text then
126
+ * `selection_response` (Relay-Server contracts/developer/openapi.yaml,
127
+ * `SuggestionResponsePart` and `SelectionResponsePart`).
128
+ */
129
+ export const readAnswer = (event) => {
130
+ if (event.event_type !== "message.received")
131
+ return undefined;
132
+ const parts = event.data.parts ?? [];
133
+ const card = suggestionReply(parts, event.data.reply_to);
134
+ if (card)
135
+ return { messageId: card.reply_to.message_id, id: card.id };
136
+ const picked = selectionReply(parts, event.data.reply_to);
137
+ const id = picked?.selected_ids?.[0] ?? picked?.selected_values[0];
138
+ return picked && id !== undefined ? { messageId: picked.reply_to.message_id, id } : undefined;
139
+ };
140
+ /**
141
+ * One per bridge process. `ask` sends the card and waits; `take` reads every
142
+ * event first and keeps the answers to its own cards, so they never start a turn.
143
+ */
144
+ export class OwnerApprovals {
145
+ #client;
146
+ #say;
147
+ /** Open prompts, by the message id of each owner's card. */
148
+ #pending = new Map();
149
+ /** Cards already settled, so a late tap on one is dropped quietly. */
150
+ #settled = new Set();
151
+ /** Cards being sent: Relay has not yet named their message ids. */
152
+ #sending = 0;
153
+ /**
154
+ * Answers to cards this process does not hold yet, kept while a card is
155
+ * being sent: the owner can tap a card before the response that names it
156
+ * arrives. Forgotten once nothing is being sent.
157
+ */
158
+ #early = new Map();
159
+ constructor(options) {
160
+ this.#client = options.client;
161
+ this.#say = options.say;
162
+ }
163
+ async ask(request) {
164
+ if (request.signal?.aborted)
165
+ return { reason: "aborted" };
166
+ // A prompt no card can carry fails loudly to the harness, before anything is sent.
167
+ let part;
168
+ try {
169
+ part = approvalPart(request);
170
+ }
171
+ catch (error) {
172
+ this.#say(error instanceof Error ? error.message : String(error));
173
+ throw error;
174
+ }
175
+ // Read fresh each time, so an owner who links a phone while the bridge
176
+ // runs is asked from the next prompt on.
177
+ let me;
178
+ try {
179
+ me = await this.#client.me.retrieve();
180
+ }
181
+ catch (error) {
182
+ this.#say(`${request.harness} was not allowed to use ${request.tool}: Relay did not say who owns this agent (${error instanceof Error ? error.message : String(error)}).`);
183
+ return { reason: "unsent" };
184
+ }
185
+ const owners = me.owner_people ?? [];
186
+ if (!owners.length) {
187
+ this.#say(noOwnerLine(request));
188
+ return { reason: "no_owner" };
189
+ }
190
+ let settle;
191
+ const done = new Promise((resolve) => { settle = resolve; });
192
+ const pending = { request, owners: new Set(owners.map((owner) => owner.handle)), cards: [], settle };
193
+ for (const owner of owners) {
194
+ this.#sending += 1;
195
+ try {
196
+ // The chat between the agent and this owner: Relay reuses the direct
197
+ // chat of the pair (Relay-Server messaging.ts, `createOrReuseChat`).
198
+ const sent = await this.#client.chats.create({
199
+ from: me.handle,
200
+ to: [owner.handle],
201
+ message: { parts: [part] },
202
+ });
203
+ const card = { chatId: sent.chat.id, messageId: sent.chat.message.id, owner: owner.handle };
204
+ pending.cards.push(card);
205
+ this.#pending.set(card.messageId, pending);
206
+ const early = this.#early.get(card.messageId);
207
+ if (early) {
208
+ this.#early.delete(card.messageId);
209
+ await this.take(early);
210
+ }
211
+ }
212
+ catch (error) {
213
+ this.#say(`The approval card did not reach @${owner.handle}: ${error instanceof Error ? error.message : String(error)}.`);
214
+ }
215
+ finally {
216
+ this.#sending -= 1;
217
+ if (this.#sending === 0)
218
+ this.#early.clear();
219
+ }
220
+ }
221
+ if (!pending.cards.length) {
222
+ this.#say(`${request.harness} was not allowed to use ${request.tool}: no owner could be asked.`);
223
+ return { reason: "unsent" };
224
+ }
225
+ this.#say(`Asked ${pending.cards.map((card) => `@${card.owner}`).join(", ")} to approve ${request.tool}.`);
226
+ const timeoutMs = request.timeoutMs ?? APPROVAL_TIMEOUT_MS;
227
+ const timer = setTimeout(() => settle({ reason: "timeout" }), timeoutMs);
228
+ const abort = () => settle({ reason: "aborted" });
229
+ request.signal?.addEventListener("abort", abort, { once: true });
230
+ const outcome = await done;
231
+ clearTimeout(timer);
232
+ request.signal?.removeEventListener("abort", abort);
233
+ for (const card of pending.cards) {
234
+ this.#pending.delete(card.messageId);
235
+ this.#settled.add(card.messageId);
236
+ }
237
+ // The card cannot change once sent, so what happened is a reply to it in
238
+ // every owner's chat.
239
+ await Promise.all(pending.cards.map(async (card) => {
240
+ try {
241
+ await this.#client.chats.messages.send(card.chatId, {
242
+ message: { parts: [{ type: "text", value: outcomeLine(outcome) }], reply_to: { message_id: card.messageId, part_index: 0 } },
243
+ });
244
+ }
245
+ catch { /* The harness already has its answer; an unanswered card is the lesser harm. */ }
246
+ }));
247
+ this.#say(`${request.tool}: ${outcomeLine(outcome)}`);
248
+ return outcome;
249
+ }
250
+ /**
251
+ * Whether this event is an answer to one of this process's approval cards.
252
+ * Such an event is handled here and must not start a turn.
253
+ */
254
+ async take(event) {
255
+ const answer = readAnswer(event);
256
+ if (!answer || event.event_type !== "message.received")
257
+ return false;
258
+ if (this.#settled.has(answer.messageId))
259
+ return true;
260
+ const pending = this.#pending.get(answer.messageId);
261
+ // An answer to an approval card this process does not hold (sent before a
262
+ // restart, or by another bridge of the same agent) is dropped quietly: the
263
+ // harness that asked is gone, and the tap is not a message to answer.
264
+ if (!pending) {
265
+ if (!isApprovalReplyId(answer.id))
266
+ return false;
267
+ // A card being sent right now may be this one.
268
+ if (this.#sending > 0)
269
+ this.#early.set(answer.messageId, event);
270
+ return true;
271
+ }
272
+ const sender = event.data.sender_handle;
273
+ // A sender with no Handle, or anyone but an owner, is not registered, and
274
+ // the card stays open.
275
+ if (!sender || sender.kind !== "user" || !pending.owners.has(sender.handle))
276
+ return true;
277
+ const choice = choiceFor(pending.request, answer.id);
278
+ if (!choice)
279
+ return true;
280
+ pending.settle({ reason: "answered", choice, by: sender.handle });
281
+ return true;
282
+ }
283
+ }
284
+ /** What a harness is told when a prompt ends without an allow (Inkbox's words, sessions.py.txt:1249). */
285
+ export const denialMessage = (outcome) => {
286
+ switch (outcome.reason) {
287
+ case "timeout": return "The agent's owner did not answer in time, so treating that as not approved.";
288
+ case "no_owner": return "Nobody who owns this agent can be asked, so treating that as not approved.";
289
+ case "unsent": return "The agent's owner could not be asked, so treating that as not approved.";
290
+ case "aborted": return "The request was cancelled, so treating that as not approved.";
291
+ default: return "The agent's owner denied this, so treating that as not approved.";
292
+ }
293
+ };
294
+ /** The input field the card's summary shows, in order of preference. */
295
+ const summaryKey = (input) => ["command", "cmd", "file_path", "path", "notebook_path", "url", "pattern", "query"].find((key) => {
296
+ const value = input[key];
297
+ return (typeof value === "string" && value.trim() !== "") || (Array.isArray(value) && value.every((part) => typeof part === "string"));
298
+ });
299
+ /** One line of a tool input, for the card's summary. */
300
+ export const inputSummary = (input) => {
301
+ const key = summaryKey(input);
302
+ const value = key === undefined ? undefined : input[key];
303
+ if (typeof value === "string")
304
+ return value;
305
+ if (Array.isArray(value))
306
+ return value.join(" ");
307
+ return JSON.stringify(input);
308
+ };
309
+ /** Whether a value says anything: not absent, false, blank, or empty. */
310
+ const says = (value) => {
311
+ if (value === undefined || value === null || value === false)
312
+ return false;
313
+ if (typeof value === "string")
314
+ return value.trim() !== "";
315
+ if (Array.isArray(value))
316
+ return value.length > 0;
317
+ if (typeof value === "object")
318
+ return Object.keys(value).length > 0;
319
+ return true;
320
+ };
321
+ /** Whether an input says anything in a field other than `shown`, the ones the card draws. */
322
+ export const beyond = (input, shown) => Object.entries(input).some(([key, value]) => !shown.includes(key) && says(value));
323
+ /**
324
+ * A tool input as its card shows it: the summarized value, Bash's own
325
+ * description as the note, the readable detail, and whether the input holds
326
+ * more. An input with no field to summarize is summarized whole.
327
+ */
328
+ export const inputCard = (input) => {
329
+ const key = summaryKey(input);
330
+ const description = key === "command" && typeof input.description === "string" && input.description.trim() ? input.description : undefined;
331
+ return {
332
+ summary: inputSummary(input),
333
+ ...(description !== undefined ? { note: description } : {}),
334
+ detail: inputDetail(input),
335
+ extra: key !== undefined && beyond(input, description !== undefined ? [key, "description"] : [key]),
336
+ };
337
+ };
338
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
339
+ const isScalar = (value) => value === null || typeof value !== "object";
340
+ /** A value on one line, for an item of a list. */
341
+ const inline = (value) => {
342
+ if (typeof value === "string")
343
+ return value;
344
+ if (isRecord(value))
345
+ return Object.entries(value).map(([key, item]) => `${key}: ${inline(item)}`).join(", ");
346
+ if (Array.isArray(value))
347
+ return value.map(inline).join(", ");
348
+ return String(value);
349
+ };
350
+ /** One `name: value` line per field; a multi-line value kept as it is, a nested one indented under its name. */
351
+ const fields = (input, indent = "") => Object.entries(input).filter(([, value]) => value !== undefined).flatMap(([key, value]) => {
352
+ if (isRecord(value))
353
+ return [`${indent}${key}:`, ...fields(value, `${indent} `)];
354
+ if (Array.isArray(value) && !value.every(isScalar))
355
+ return [`${indent}${key}:`, ...value.map((item) => `${indent} - ${inline(item)}`)];
356
+ return [`${indent}${key}: ${inline(value)}`];
357
+ });
358
+ /** A replacement as a diff: `-` before each old line, `+` before each new one. */
359
+ const diff = (before, after) => [
360
+ ...before.split("\n").map((line) => `- ${line}`),
361
+ ...after.split("\n").map((line) => `+ ${line}`),
362
+ ];
363
+ const isReplacement = (value) => isRecord(value) && typeof value.old_string === "string" && typeof value.new_string === "string";
364
+ /**
365
+ * The whole tool input as a person reads it, for the card's description: an Edit is its
366
+ * file and a diff (a MultiEdit, a diff per edit), a Write its file and the
367
+ * content, anything else one `name: value` line per field.
368
+ */
369
+ export const inputDetail = (input) => {
370
+ if (typeof input === "string")
371
+ return input;
372
+ if (!isRecord(input)) {
373
+ try {
374
+ return JSON.stringify(input) ?? String(input);
375
+ }
376
+ catch {
377
+ return String(input);
378
+ }
379
+ }
380
+ const { file_path: path, ...rest } = input;
381
+ if (typeof path === "string" && isReplacement(input)) {
382
+ const { old_string: _old, new_string: _new, ...other } = rest;
383
+ return [path, ...diff(input.old_string, input.new_string), ...fields(other)].join("\n");
384
+ }
385
+ if (typeof path === "string" && Array.isArray(input.edits) && input.edits.every(isReplacement)) {
386
+ const { edits, ...other } = rest;
387
+ return [
388
+ path,
389
+ ...edits.flatMap(({ old_string: before, new_string: after, ...more }) => [...diff(before, after), ...fields(more)]),
390
+ ...fields(other),
391
+ ].join("\n");
392
+ }
393
+ if (typeof path === "string" && typeof input.content === "string") {
394
+ const { content, ...other } = rest;
395
+ return [path, content, ...fields(other)].join("\n");
396
+ }
397
+ return fields(input).join("\n");
398
+ };
399
+ /**
400
+ * Pi's dialogs, answered by the owners. A confirm is Yes or No, an allow and
401
+ * a deny; a select offers the extension's own options, each as it wrote it.
402
+ * The card's first line is the extension's own title.
403
+ */
404
+ export const piApprovals = (approvals) => ({
405
+ dialog: async (dialog) => {
406
+ const choices = dialog.method === "confirm"
407
+ ? [{ id: "Yes", label: "Yes", decision: "allow_once" }, { id: "No", label: "No", decision: "deny" }]
408
+ : dialog.options.map((option) => ({ id: option, label: option, decision: "choice" }));
409
+ const outcome = await approvals.ask({
410
+ harness: "Pi",
411
+ tool: oneLine(dialog.title).slice(0, 80) || "a question",
412
+ title: dialog.title.trim() || "Pi asks a question.",
413
+ summary: dialog.message ?? "",
414
+ detail: [dialog.title, dialog.message].filter(Boolean).join("\n\n"),
415
+ // The title is the card's first line and the message its summary.
416
+ extra: false,
417
+ choices,
418
+ ...(dialog.timeoutMs !== undefined ? { timeoutMs: dialog.timeoutMs } : {}),
419
+ ...(dialog.signal ? { signal: dialog.signal } : {}),
420
+ });
421
+ return outcome.choice?.id;
422
+ },
423
+ take: (event) => approvals.take(event),
424
+ });
package/dist/index.d.ts CHANGED
@@ -170,4 +170,6 @@ export declare const sendToChat: (relay: Relay, chatId: string, key: string, ans
170
170
  */
171
171
  export declare const typing: (relay: Relay, chatId: string, on: boolean) => Promise<void>;
172
172
  export declare const runPiChannel: (options: PiChannelOptions, signal?: AbortSignal) => Promise<void>;
173
- export {};
173
+ /** The owner approval cards, shared with the Relay CLI. */
174
+ export * from "./approvals.js";
175
+ export * from "./session-approvals.js";
package/dist/index.js CHANGED
@@ -545,3 +545,6 @@ export const typing = (relay, chatId, on) => Promise.resolve()
545
545
  console.error(`Relay: typing indicator failed: ${error instanceof Error ? error.message : String(error)}`);
546
546
  });
547
547
  export const runPiChannel = (options, signal) => new PiChannel(options).run(signal);
548
+ /** The owner approval cards, shared with the Relay CLI. */
549
+ export * from "./approvals.js";
550
+ export * from "./session-approvals.js";
package/dist/native.js CHANGED
@@ -7,6 +7,8 @@ import { RELAY_CHAT_ID_ENV, RELAY_HINT_SECTION, relayHint, runPiChannel } from "
7
7
  import { relayTools } from "./tools.js";
8
8
  import { registerRelayAdapter, relayChannelAdapter, RELAY_ADAPTER } from "./pi-channels.js";
9
9
  import { SessionChannel } from "./session.js";
10
+ import { OwnerApprovals } from "./approvals.js";
11
+ import { registerRelayAuthorizer, relayAuthorizer } from "./session-approvals.js";
10
12
  import { transcribeCpp } from "./voice.js";
11
13
  let controller;
12
14
  let running;
@@ -58,6 +60,32 @@ const sessionMode = (settings) => (process.env.RELAY_PI_MODE?.trim() || settings
58
60
  const attachSession = (pi) => {
59
61
  let channel;
60
62
  let stop;
63
+ // The owner's approval card, as a pi-permission-system chain link: registered
64
+ // once both the channel and that extension's session are up, in either order.
65
+ let link;
66
+ let permissionsSession;
67
+ let unregister;
68
+ const register = () => {
69
+ if (unregister || !link || !permissionsSession)
70
+ return;
71
+ try {
72
+ unregister = registerRelayAuthorizer(permissionsSession, (ask) => link.authorize(ask));
73
+ }
74
+ catch (error) {
75
+ console.error(`Relay: the approval link was not registered: ${error instanceof Error ? error.message : String(error)}`);
76
+ }
77
+ };
78
+ pi.events.on("permissions:ready", (data) => {
79
+ const sessionId = data?.sessionId;
80
+ if (typeof sessionId !== "string" || !sessionId)
81
+ return;
82
+ if (sessionId !== permissionsSession) {
83
+ unregister?.();
84
+ unregister = undefined;
85
+ permissionsSession = sessionId;
86
+ }
87
+ register();
88
+ });
61
89
  pi.on("session_start", async (_event, ctx) => {
62
90
  // Only the RPC session an app drives: a terminal Pi on the same account
63
91
  // would hold a second connection on the same token, a print or JSON run
@@ -78,9 +106,11 @@ const attachSession = (pi) => {
78
106
  const voice = settings.transcribeCpp;
79
107
  stop = new AbortController();
80
108
  const relay = new Relay({ apiKey: token, ...(baseURL ? { baseURL } : {}) });
109
+ const approvals = new OwnerApprovals({ client: relay, say: (line) => { console.error(`Relay: ${line}`); } });
81
110
  channel = new SessionChannel(pi, {
82
111
  agentToken: token,
83
112
  relay,
113
+ approvals,
84
114
  isIdle: () => ctx.isIdle(),
85
115
  lastChatFile: join(agentDir(), "relay-last-chat.json"),
86
116
  ...(baseURL ? { baseURL } : {}),
@@ -88,6 +118,8 @@ const attachSession = (pi) => {
88
118
  ...(voice ? { transcribe: transcribeCpp({ module: home(voice.module), model: home(voice.model) }) } : {}),
89
119
  });
90
120
  const current = channel;
121
+ link = relayAuthorizer({ approvals, fromRelay: () => current.fromRelay });
122
+ register();
91
123
  registerRelayTools(pi, relay, () => current.chatId, () => current.messageId, (chat) => current.latestIn(chat));
92
124
  void channel.run(stop.signal).catch((error) => {
93
125
  console.error(`Relay: the session channel stopped: ${error instanceof Error ? error.message : String(error)}`);
@@ -101,8 +133,12 @@ const attachSession = (pi) => {
101
133
  pi.on("session_shutdown", async () => {
102
134
  stop?.abort();
103
135
  channel?.stop();
136
+ unregister?.();
104
137
  stop = undefined;
105
138
  channel = undefined;
139
+ link = undefined;
140
+ unregister = undefined;
141
+ permissionsSession = undefined;
106
142
  });
107
143
  };
108
144
  /**
@@ -0,0 +1,73 @@
1
+ import { type ApprovalChoice, type OwnerApprovals } from "./approvals.js";
2
+ /**
3
+ * The owner's approval card for the Pi session Relay runs in, through
4
+ * `@gotgenes/pi-permission-system`.
5
+ *
6
+ * In RPC mode that extension asks with `ctx.ui.select`, which Pi sends to
7
+ * the app driving the session as an `extension_ui_request` (pi-permission-
8
+ * system src/authority/permission-dialog.ts, `requestPermissionDecisionFromUi`;
9
+ * Pi docs/rpc.md, "Extension UI Protocol"). Another extension in the same
10
+ * process cannot answer that dialog. What it offers instead is a live-
11
+ * authority chain link: a sibling extension registers one with
12
+ * `registerAuthorizer`, and the link answers `allow`, `deny` or `defer` before
13
+ * the dialog opens (docs/cross-extension-api.md, "Ready Event";
14
+ * docs/configuration.md, "Authorizer chain"). The operator turns it on by
15
+ * naming it in `authorizerChain`.
16
+ *
17
+ * This link sends the same card the Relay CLI sends in channel mode when the
18
+ * run asking was started by a Relay Message, and defers otherwise, so a run
19
+ * typed at the app is asked in the app as before.
20
+ */
21
+ /** The name to list in pi-permission-system's `authorizerChain`. */
22
+ export declare const RELAY_AUTHORIZER = "relay";
23
+ /** The parts of pi-permission-system's `PromptPermissionDetails` the card reads (dist/public.d.ts). */
24
+ export interface PermissionAsk {
25
+ readonly payload: {
26
+ readonly request: {
27
+ readonly surface: string;
28
+ readonly toolName: string | null;
29
+ readonly value: string;
30
+ };
31
+ };
32
+ readonly sessionApproval?: {
33
+ readonly grants: readonly {
34
+ readonly surface: string;
35
+ readonly pattern: string;
36
+ }[];
37
+ };
38
+ readonly accessIntent?: {
39
+ readonly surface: string;
40
+ };
41
+ }
42
+ /** pi-permission-system's `AuthorizerVerdict`. */
43
+ export type PermissionVerdict = {
44
+ kind: "allow";
45
+ } | {
46
+ kind: "deny";
47
+ reason?: string;
48
+ } | {
49
+ kind: "defer";
50
+ };
51
+ export declare const RELAY_APPROVAL_CHOICES: readonly ApprovalChoice[];
52
+ export interface RelayAuthorizerOptions {
53
+ readonly approvals: Pick<OwnerApprovals, "ask">;
54
+ /** Whether the run asking now was started by a Relay Message. */
55
+ readonly fromRelay: () => boolean;
56
+ /** How long the owner has to answer; `APPROVAL_TIMEOUT_MS` (10 minutes) when absent. */
57
+ readonly timeoutMs?: number;
58
+ }
59
+ /**
60
+ * The link's `authorize`. An answered card allows or denies; a card nobody
61
+ * answers in time denies; a card that could not be sent defers to the app.
62
+ */
63
+ export declare const relayAuthorizer: (options: RelayAuthorizerOptions) => {
64
+ authorize(ask: PermissionAsk): Promise<PermissionVerdict>;
65
+ /** Forgets this session's grants, at `session_shutdown`. */
66
+ reset(): void;
67
+ };
68
+ /**
69
+ * Registers the link with the session's pi-permission-system, read from the
70
+ * process-global map its docs name, so Relay needs no dependency on it. Does
71
+ * nothing when it is not loaded. Returns the disposer.
72
+ */
73
+ export declare const registerRelayAuthorizer: (sessionId: string, authorize: (ask: PermissionAsk) => Promise<PermissionVerdict>) => (() => void) | undefined;
@@ -0,0 +1,97 @@
1
+ import { denialMessage } from "./approvals.js";
2
+ /**
3
+ * The owner's approval card for the Pi session Relay runs in, through
4
+ * `@gotgenes/pi-permission-system`.
5
+ *
6
+ * In RPC mode that extension asks with `ctx.ui.select`, which Pi sends to
7
+ * the app driving the session as an `extension_ui_request` (pi-permission-
8
+ * system src/authority/permission-dialog.ts, `requestPermissionDecisionFromUi`;
9
+ * Pi docs/rpc.md, "Extension UI Protocol"). Another extension in the same
10
+ * process cannot answer that dialog. What it offers instead is a live-
11
+ * authority chain link: a sibling extension registers one with
12
+ * `registerAuthorizer`, and the link answers `allow`, `deny` or `defer` before
13
+ * the dialog opens (docs/cross-extension-api.md, "Ready Event";
14
+ * docs/configuration.md, "Authorizer chain"). The operator turns it on by
15
+ * naming it in `authorizerChain`.
16
+ *
17
+ * This link sends the same card the Relay CLI sends in channel mode when the
18
+ * run asking was started by a Relay Message, and defers otherwise, so a run
19
+ * typed at the app is asked in the app as before.
20
+ */
21
+ /** The name to list in pi-permission-system's `authorizerChain`. */
22
+ export const RELAY_AUTHORIZER = "relay";
23
+ export const RELAY_APPROVAL_CHOICES = [
24
+ { id: "allow_once", label: "Allow once", decision: "allow_once" },
25
+ { id: "allow_session", label: "Allow for session", decision: "allow_session" },
26
+ { id: "deny", label: "Deny", decision: "deny" },
27
+ ];
28
+ /**
29
+ * The surfaces whose `allow` the chain caps to `defer` ("bounded-delegation
30
+ * checkpoint", docs/configuration.md): `path` and `external_directory` with
31
+ * their directional members. An owner asked about one could not approve it.
32
+ */
33
+ const capped = (surface) => /^(path|external_directory)(_|$)/u.test(surface);
34
+ /**
35
+ * The grant an "Allow for session" stands for: the session patterns
36
+ * pi-permission-system suggests for the ask, else the exact surface and
37
+ * value. A later ask is allowed without a card only when it carries the same
38
+ * key, so the link never allows more than the owner saw.
39
+ */
40
+ const grantKey = (ask) => {
41
+ const grants = ask.sessionApproval?.grants;
42
+ if (grants?.length)
43
+ return JSON.stringify(grants.map((grant) => [grant.surface, grant.pattern]).sort());
44
+ return JSON.stringify([ask.payload.request.surface, ask.payload.request.value]);
45
+ };
46
+ /**
47
+ * The link's `authorize`. An answered card allows or denies; a card nobody
48
+ * answers in time denies; a card that could not be sent defers to the app.
49
+ */
50
+ export const relayAuthorizer = (options) => {
51
+ const granted = new Set();
52
+ return {
53
+ reset: () => { granted.clear(); },
54
+ authorize: async (ask) => {
55
+ if (!options.fromRelay() || capped(ask.accessIntent?.surface ?? ask.payload.request.surface))
56
+ return { kind: "defer" };
57
+ const key = grantKey(ask);
58
+ if (granted.has(key))
59
+ return { kind: "allow" };
60
+ const { surface, toolName, value } = ask.payload.request;
61
+ const tool = toolName ?? surface;
62
+ const patterns = ask.sessionApproval?.grants.map((grant) => grant.pattern).join(", ");
63
+ const outcome = await options.approvals.ask({
64
+ harness: "Pi",
65
+ tool,
66
+ title: `Pi asks to use ${tool}.`,
67
+ summary: value,
68
+ ...(patterns ? { note: `Allow for session covers ${patterns}` } : {}),
69
+ detail: value,
70
+ extra: false,
71
+ choices: RELAY_APPROVAL_CHOICES,
72
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
73
+ });
74
+ switch (outcome.choice?.decision) {
75
+ case "allow_session":
76
+ granted.add(key);
77
+ return { kind: "allow" };
78
+ case "allow_once": return { kind: "allow" };
79
+ case "deny": return { kind: "deny", reason: denialMessage(outcome) };
80
+ default: break;
81
+ }
82
+ // Nobody answered in time: not approved. No owner could be asked: the app asks.
83
+ return outcome.reason === "timeout" ? { kind: "deny", reason: denialMessage(outcome) } : { kind: "defer" };
84
+ },
85
+ };
86
+ };
87
+ /** pi-permission-system's per-session services (src/service.ts, `SESSION_SERVICES_KEY`; docs/cross-extension-api.md, "How It Works"). */
88
+ const SERVICES = Symbol.for("@gotgenes/pi-permission-system:session-services");
89
+ /**
90
+ * Registers the link with the session's pi-permission-system, read from the
91
+ * process-global map its docs name, so Relay needs no dependency on it. Does
92
+ * nothing when it is not loaded. Returns the disposer.
93
+ */
94
+ export const registerRelayAuthorizer = (sessionId, authorize) => {
95
+ const services = globalThis[SERVICES];
96
+ return services?.get(sessionId)?.registerAuthorizer(RELAY_AUTHORIZER, authorize);
97
+ };
package/dist/session.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import Relay, { type MediaPartResponse, type MessageWebhookData, type RelayWebhookEvent } from "@relaymessenger/sdk";
2
+ import type { OwnerApprovals } from "./approvals.js";
2
3
  import type { Transcribe } from "./voice.js";
3
4
  export interface TextBlock {
4
5
  readonly type: "text";
@@ -44,6 +45,8 @@ export interface SessionChannelOptions {
44
45
  * run no Message started (a helper's result) sends its words there.
45
46
  */
46
47
  readonly lastChatFile?: string;
48
+ /** The owner approval cards: a tap on one answers it and is not a Message for the session. */
49
+ readonly approvals?: Pick<OwnerApprovals, "take">;
47
50
  }
48
51
  /**
49
52
  * The line sent when a run fails before it has words, so the person is not
@@ -131,6 +134,8 @@ export declare class SessionChannel {
131
134
  * turn started the newest waiting background run, else the last chat.
132
135
  */
133
136
  get chatId(): string | undefined;
137
+ /** Whether a Relay Message started or steered the run going now. */
138
+ get fromRelay(): boolean;
134
139
  /** The Message the tools react and thread to, chosen as `chatId` is. */
135
140
  get messageId(): string | undefined;
136
141
  /** The newest Message of a chat the session has heard from. */
package/dist/session.js CHANGED
@@ -229,7 +229,13 @@ export class SessionChannel {
229
229
  const inflight = this.#inflight.get(event.event_id);
230
230
  if (inflight)
231
231
  return inflight;
232
- if (this.#seen.has(event.event_id) || !accepts(event, this.#options.senders))
232
+ if (this.#seen.has(event.event_id))
233
+ return;
234
+ if (await this.#options.approvals?.take(event)) {
235
+ this.#seen.add(event.event_id);
236
+ return;
237
+ }
238
+ if (!accepts(event, this.#options.senders))
233
239
  return;
234
240
  const handoff = (async () => {
235
241
  const content = await sessionContent(event.data, {
@@ -259,6 +265,10 @@ export class SessionChannel {
259
265
  get chatId() {
260
266
  return (this.#pending?.data ?? this.#background.at(-1)?.data ?? this.#last)?.chat.id;
261
267
  }
268
+ /** Whether a Relay Message started or steered the run going now. */
269
+ get fromRelay() {
270
+ return this.#pending !== undefined;
271
+ }
262
272
  /** The Message the tools react and thread to, chosen as `chatId` is. */
263
273
  get messageId() {
264
274
  return (this.#pending?.data ?? this.#background.at(-1)?.data ?? this.#last)?.id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/pi",
3
- "version": "0.1.14",
3
+ "version": "0.1.15-staging.0",
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.5.5"
37
+ "@relaymessenger/sdk": "0.5.5-staging.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "0.86.0",