pi-roundtable-webchat 0.14.1 → 0.15.1
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/CHANGELOG.md +18 -0
- package/README.md +5 -3
- package/package.json +3 -3
- package/src/client-calls.ts +18 -0
- package/src/prompts.ts +27 -4
- package/src/protocol.ts +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.15.1] - 2026-10-15
|
|
9
|
+
|
|
10
|
+
- Release in lockstep with pi-roundtable 0.15.1; no package-specific behavior changes. The core peer range stays `>=0.15.0 <0.16.0`.
|
|
11
|
+
|
|
12
|
+
## [0.15.0] - 2026-10-14
|
|
13
|
+
|
|
14
|
+
Release in lockstep with pi-roundtable 0.15.0. The core peer range moves to `>=0.15.0 <0.16.0`.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The approval prompt's `message` is the card text the core builds, and for a tool whose parameter schema is known it is now a list of labelled fields in markdown (`**label**: value`, long and multi-line values in fenced blocks) instead of one JSON block; a client that read the JSON out of `message` should read `approval.input`, which is unchanged and whole. A tool without a schema keeps the JSON block.
|
|
19
|
+
- `PromptOutcome` gains `withdrawn`: a client that switches over the outcomes must handle it (close the prompt as for `cancelled`).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `prompt_closed` may carry the outcome `withdrawn` (new in `PromptOutcome`) and a `note`: the plugin that asked for an approval took it back (`ApprovalOptions.supersede`), and `note` is its reason, when it gave one as a text. The prompt is removed from the open ones a reconnect resends, and an answer to it finds `unknown_prompt`. A client that does not know the outcome should close the prompt as it does for `cancelled`. The turn reads a withdrawn approval as cancelled; the core tells the model it was withdrawn.
|
|
24
|
+
- `client_call` may carry `approval: { prompt }` (`ClientCallOptions.approval` in the core): the id of the approval prompt the tool's call acts on, written into the frame unchanged when the tool passes one and absent otherwise. The server accepts a text of 1 to 128 characters and fails the call otherwise.
|
|
25
|
+
|
|
8
26
|
## [0.14.1] - 2026-10-14
|
|
9
27
|
|
|
10
28
|
- Release in lockstep with pi-roundtable 0.14.1; no package-specific behavior changes. The core peer range stays `>=0.14.0 <0.15.0`.
|
package/README.md
CHANGED
|
@@ -214,14 +214,14 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
214
214
|
| `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
|
|
215
215
|
| `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
|
|
216
216
|
| `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
|
|
217
|
-
| `{ type: "progress", conversation, event }` | What the running turn writes and which tools it runs: `{ type: "text", delta }`, `{ type: "tool_start", id, tool, preview? }`, or `{ type: "tool_end", id, tool, ok, refused? }`. Never the thinking, never a tool's full arguments. A `tool_end` that a hold refused (not a real failure) has `ok: false` and `refused`: `declined` (turned down on its approval card), `expired` (a card was shown and went unanswered; the call is held for the next message), `pending` (the card is still open) or `held` (no card could be shown: the person's tier is below the call's, or it could not be posted; the call is held for the next message). `refused` is absent for a success and for a real failure. |
|
|
217
|
+
| `{ type: "progress", conversation, event }` | What the running turn writes and which tools it runs: `{ type: "text", delta }`, `{ type: "tool_start", id, tool, preview? }`, or `{ type: "tool_end", id, tool, ok, refused? }`. Never the thinking, never a tool's full arguments. A `tool_end` that a hold refused (not a real failure) has `ok: false` and `refused`: `declined` (turned down on its approval card), `expired` (a card was shown and went unanswered; the call is held for the next message), `pending` (the card is still open), `withdrawn` (the plugin that held the call took it back) or `held` (no card could be shown: the person's tier is below the call's, or it could not be posted; the call is held for the next message). `refused` is absent for a success and for a real failure. |
|
|
218
218
|
| `{ type: "reply", conversation, text, thinking?, files? }` | The turn's answer in full markdown, with its files inline as `{ name, data }` (base64). |
|
|
219
219
|
| `{ type: "failed", conversation, stopped }` | The turn ended without an answer: it failed, the host refused it before it ran (for example when its conversation could not be recorded), or it was stopped. The cause stays in the host's log. |
|
|
220
220
|
| `{ type: "prompt", conversation, prompt }` | The turn asks you: `{ id, kind: "approval", title, message, approval? }`, or `{ id, kind: "ask", title, question, options, multi, allowOther }`. |
|
|
221
|
-
| `{ type: "prompt_closed", conversation, prompt, outcome }` | The prompt closed: `approved`, `declined`, `answered`, `expired`,
|
|
221
|
+
| `{ type: "prompt_closed", conversation, prompt, outcome, note? }` | The prompt closed: `approved`, `declined`, `answered`, `expired`, `cancelled` (the turn stopped), or `withdrawn` (the plugin that asked took the approval back; `note` may carry its reason in a few words). |
|
|
222
222
|
| `{ type: "notice", notice: { id, text, createdAt, readAt } }` | A durable private inbox entry; `readAt` is `null` until read. Fetch the REST inbox to recover entries missed while offline. |
|
|
223
223
|
| `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
|
|
224
|
-
| `{ type: "client_call", conversation, call, name, args, timeoutMs }` | The assistant asks this connection, and no other, to run the function `name` it declared and to answer with `client_result` within `timeoutMs`. See [Client calls](#client-calls). |
|
|
224
|
+
| `{ type: "client_call", conversation, call, name, args, timeoutMs, approval? }` | The assistant asks this connection, and no other, to run the function `name` it declared and to answer with `client_result` within `timeoutMs`. `approval` (`{ prompt }`) is present only when the tool that made the call named the approval it acts on: `prompt` is the `id` of the `prompt` frame (an approval card) the person approved. See [Client calls](#client-calls). |
|
|
225
225
|
| `{ type: "client_cancel", call, reason }` | The call is no longer wanted (`turn_stopped`, `timeout`, or `turn_ended`): stop running it; an answer is dropped. |
|
|
226
226
|
| `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id, prompt id or call id it was about. |
|
|
227
227
|
|
|
@@ -324,6 +324,8 @@ A client that can says so, and the assistant's tools then call it:
|
|
|
324
324
|
3. The client runs the function and answers with `client_result` on the same connection. A binary result, such as a screenshot, does not fit a frame: it `PUT`s each part to `<path>/conversations/<conversation>/client-calls/<call>/parts/<n>` first (see the [REST API](#rest-api)), then names the count in `parts`.
|
|
325
325
|
4. The tool gets `{ ok: true, value, parts }`, or `{ ok: false, error }`: `refused` or `failed` as the client reported, `timeout` (no answer in time; the client gets `client_cancel`), `cancelled` (the turn was stopped, or the client reported `stopped`; the client gets `client_cancel`), `disconnected` (the connection closed first), `too_large` (the `value` passed `maxValueBytes`), `busy` (a call is already open on this connection, or the connection was asked `perMinute` calls in the last minute), `no_client`, or `failed` for a bad argument or a part that did not arrive.
|
|
326
326
|
|
|
327
|
+
A call may carry `approval: { prompt }`: a tool that acts on something the person approved on an approval card (`turn.approvedPrompt` in the core) passes that card's id, and the server writes it into the frame unchanged, after checking only that it is a text of 1 to 128 characters (otherwise the tool's call fails). The client, not the server, decides what it is worth: it compares `prompt` with the `prompt` frames it showed and the `approval` frames it sent, and refuses a function that needs an approval when the id is absent or is not one it sent as approved. A call whose tool names none has no `approval` field.
|
|
328
|
+
|
|
327
329
|
This is the opposite of an approval card: a card goes to every connection of the person and survives a reconnect, a call goes to one connection, ends when it closes, and is never sent again after a reconnect. Only the connection a call was sent to may answer it, and only as the same person; an answer from another connection, even the same person's, gets `error` `unknown_call`. A part is accepted for an open call of that person and conversation, whose id only the right connection received.
|
|
328
330
|
The `value`, the parts and the `message` come from the client, so the tool treats them as untrusted data; the server accepts only JSON of at most `maxValueBytes`, images of the three types whose bytes match, and a message cut to one plain line of 300 characters.
|
|
329
331
|
Stopping the conversation, or the turn ending, cancels the call in flight.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-webchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"jose": "6.2.12"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"pi-roundtable": ">=0.
|
|
39
|
+
"pi-roundtable": ">=0.15.0 <0.16.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "2.5.15",
|
|
43
43
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
44
44
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
45
45
|
"@types/bun": "1.4.2",
|
|
46
|
-
"pi-roundtable": "0.
|
|
46
|
+
"pi-roundtable": "0.15.1",
|
|
47
47
|
"typebox": "1.3.34",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
}
|
package/src/client-calls.ts
CHANGED
|
@@ -47,6 +47,15 @@ function plain(text: string): string {
|
|
|
47
47
|
|
|
48
48
|
const bytesOf = (text: string) => new TextEncoder().encode(text).length;
|
|
49
49
|
|
|
50
|
+
/** The longest prompt id a call may name as its approval, as long as the ids a client may answer. */
|
|
51
|
+
const APPROVAL_PROMPT_CHARS = 128;
|
|
52
|
+
|
|
53
|
+
/** Whether `prompt` can be a prompt id: a text of 1 to 128 characters. */
|
|
54
|
+
const validPrompt = (prompt: unknown): prompt is string =>
|
|
55
|
+
typeof prompt === "string" &&
|
|
56
|
+
prompt.length > 0 &&
|
|
57
|
+
prompt.length <= APPROVAL_PROMPT_CHARS;
|
|
58
|
+
|
|
50
59
|
const failure = (
|
|
51
60
|
error: ClientCallError,
|
|
52
61
|
message: string,
|
|
@@ -208,6 +217,14 @@ export class ClientDesk {
|
|
|
208
217
|
"failed",
|
|
209
218
|
`the arguments must be JSON of at most ${MAX_ARGS_BYTES} bytes`,
|
|
210
219
|
);
|
|
220
|
+
const approval = options?.approval;
|
|
221
|
+
if (
|
|
222
|
+
approval !== undefined &&
|
|
223
|
+
(typeof approval !== "object" ||
|
|
224
|
+
approval === null ||
|
|
225
|
+
!validPrompt(approval.prompt))
|
|
226
|
+
)
|
|
227
|
+
return refuse("failed", "approval.prompt must be a prompt id");
|
|
211
228
|
if (this.#byConnection.has(connection))
|
|
212
229
|
return refuse("busy", "another call is open on this client");
|
|
213
230
|
let rate = this.#rates.get(connection);
|
|
@@ -277,6 +294,7 @@ export class ClientDesk {
|
|
|
277
294
|
name,
|
|
278
295
|
args: JSON.parse(argsJson),
|
|
279
296
|
timeoutMs,
|
|
297
|
+
...(approval ? { approval: { prompt: approval.prompt } } : {}),
|
|
280
298
|
});
|
|
281
299
|
});
|
|
282
300
|
}
|
package/src/prompts.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type Prompts,
|
|
8
8
|
TIERS,
|
|
9
9
|
type Tier,
|
|
10
|
+
withdrawalNote,
|
|
10
11
|
} from "pi-roundtable";
|
|
11
12
|
import {
|
|
12
13
|
APPROVAL_MAX_BYTES,
|
|
@@ -28,7 +29,7 @@ interface Open {
|
|
|
28
29
|
frame: PromptFrame;
|
|
29
30
|
/** The lowest tier that may approve; questions have none. */
|
|
30
31
|
minTier?: Tier;
|
|
31
|
-
settle(outcome: PromptOutcome, answer?: OwnerAnswer): void;
|
|
32
|
+
settle(outcome: PromptOutcome, answer?: OwnerAnswer, note?: string): void;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface PromptDeskOptions {
|
|
@@ -69,6 +70,7 @@ export class PromptDesk {
|
|
|
69
70
|
minTier = "owner",
|
|
70
71
|
_wait,
|
|
71
72
|
details,
|
|
73
|
+
options,
|
|
72
74
|
) => {
|
|
73
75
|
if (!atLeast(scope.tier, minTier)) return "unavailable";
|
|
74
76
|
const outcome = await this.#ask(
|
|
@@ -83,6 +85,8 @@ export class PromptDesk {
|
|
|
83
85
|
}),
|
|
84
86
|
signal,
|
|
85
87
|
minTier,
|
|
88
|
+
options?.supersede,
|
|
89
|
+
options?.approvedPrompt,
|
|
86
90
|
);
|
|
87
91
|
return toApproval(outcome.outcome);
|
|
88
92
|
},
|
|
@@ -143,23 +147,38 @@ export class PromptDesk {
|
|
|
143
147
|
frameOf: (id: string) => PromptFrame,
|
|
144
148
|
signal: AbortSignal | undefined,
|
|
145
149
|
minTier?: Tier,
|
|
150
|
+
supersede?: AbortSignal,
|
|
151
|
+
approvedPrompt?: (prompt: string) => void,
|
|
146
152
|
): Promise<{ outcome: PromptOutcome; answer?: OwnerAnswer }> {
|
|
147
153
|
const { send, timeoutMs } = this.#options;
|
|
148
|
-
if (signal?.aborted
|
|
154
|
+
if (signal?.aborted || supersede?.aborted)
|
|
155
|
+
return Promise.resolve({ outcome: "cancelled" });
|
|
149
156
|
return new Promise((resolve) => {
|
|
150
157
|
const id = crypto.randomUUID();
|
|
151
158
|
const frame = frameOf(id);
|
|
152
159
|
const onAbort = () => settle("cancelled");
|
|
153
160
|
const timer = setTimeout(() => settle("expired"), timeoutMs);
|
|
154
|
-
const
|
|
161
|
+
const onSupersede = () =>
|
|
162
|
+
supersede &&
|
|
163
|
+
settle("withdrawn", undefined, withdrawalNote(supersede, ""));
|
|
164
|
+
const settle = (
|
|
165
|
+
outcome: PromptOutcome,
|
|
166
|
+
answer?: OwnerAnswer,
|
|
167
|
+
note?: string,
|
|
168
|
+
) => {
|
|
169
|
+
// Whichever of an answer and a withdrawal comes first settles the prompt.
|
|
155
170
|
if (!this.#open.delete(id)) return;
|
|
171
|
+
// Heard before the approval resolves, so the call that runs on it can say which card it was.
|
|
172
|
+
if (outcome === "approved") approvedPrompt?.(id);
|
|
156
173
|
clearTimeout(timer);
|
|
157
174
|
signal?.removeEventListener("abort", onAbort);
|
|
175
|
+
supersede?.removeEventListener("abort", onSupersede);
|
|
158
176
|
send(principal, {
|
|
159
177
|
type: "prompt_closed",
|
|
160
178
|
conversation,
|
|
161
179
|
prompt: id,
|
|
162
180
|
outcome,
|
|
181
|
+
...(note ? { note } : {}),
|
|
163
182
|
});
|
|
164
183
|
resolve(answer ? { outcome, answer } : { outcome });
|
|
165
184
|
};
|
|
@@ -171,6 +190,7 @@ export class PromptDesk {
|
|
|
171
190
|
settle,
|
|
172
191
|
});
|
|
173
192
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
193
|
+
supersede?.addEventListener("abort", onSupersede, { once: true });
|
|
174
194
|
send(principal, { type: "prompt", conversation, prompt: frame });
|
|
175
195
|
});
|
|
176
196
|
}
|
|
@@ -266,7 +286,10 @@ function cutStrings(value: unknown, limit: number): unknown {
|
|
|
266
286
|
|
|
267
287
|
function toApproval(outcome: PromptOutcome): Approval {
|
|
268
288
|
if (outcome === "approved" || outcome === "declined") return outcome;
|
|
269
|
-
|
|
289
|
+
// A withdrawal is read as the turn's wait ending; the gate knows it was withdrawn.
|
|
290
|
+
return outcome === "cancelled" || outcome === "withdrawn"
|
|
291
|
+
? "cancelled"
|
|
292
|
+
: "expired";
|
|
270
293
|
}
|
|
271
294
|
|
|
272
295
|
function questionFrame(
|
package/src/protocol.ts
CHANGED
|
@@ -72,13 +72,17 @@ export type PromptFrame =
|
|
|
72
72
|
allowOther: boolean;
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* How a prompt closed. `withdrawn` is an approval the plugin that asked for it took back; the
|
|
77
|
+
* frame may carry a short `note` saying why.
|
|
78
|
+
*/
|
|
76
79
|
export type PromptOutcome =
|
|
77
80
|
| "approved"
|
|
78
81
|
| "declined"
|
|
79
82
|
| "answered"
|
|
80
83
|
| "expired"
|
|
81
|
-
| "cancelled"
|
|
84
|
+
| "cancelled"
|
|
85
|
+
| "withdrawn";
|
|
82
86
|
|
|
83
87
|
/** The most functions a client may declare. */
|
|
84
88
|
export const MAX_CLIENT_TOOLS = 64;
|
|
@@ -236,6 +240,8 @@ export type ServerFrame =
|
|
|
236
240
|
conversation: string;
|
|
237
241
|
prompt: string;
|
|
238
242
|
outcome: PromptOutcome;
|
|
243
|
+
/** Why a `withdrawn` prompt was taken back, in the plugin's words; absent otherwise. */
|
|
244
|
+
note?: string;
|
|
239
245
|
}
|
|
240
246
|
/** A durable private inbox entry; fetch the REST inbox to recover missed notices. */
|
|
241
247
|
| { type: "notice"; notice: Notice }
|
|
@@ -252,6 +258,11 @@ export type ServerFrame =
|
|
|
252
258
|
name: string;
|
|
253
259
|
args: unknown;
|
|
254
260
|
timeoutMs: number;
|
|
261
|
+
/**
|
|
262
|
+
* The approval the call acts on, when the tool passed one: `prompt` is the id of the
|
|
263
|
+
* `prompt` frame the person approved. Absent for a call that acts on no approval.
|
|
264
|
+
*/
|
|
265
|
+
approval?: { prompt: string };
|
|
255
266
|
}
|
|
256
267
|
/** The call is no longer wanted: stop running it; any answer is dropped. */
|
|
257
268
|
| { type: "client_cancel"; call: string; reason: ClientCancelReason }
|