@relaymessenger/pi 0.1.14-staging.0 → 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 +18 -0
- package/dist/approvals.d.ts +194 -0
- package/dist/approvals.js +424 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -0
- package/dist/native.js +36 -0
- package/dist/session-approvals.d.ts +73 -0
- package/dist/session-approvals.js +97 -0
- package/dist/session.d.ts +5 -0
- package/dist/session.js +11 -1
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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;
|