pi-roundtable-webchat 0.14.0 → 0.15.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/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.0] - 2026-10-14
9
+
10
+ Release in lockstep with pi-roundtable 0.15.0. The core peer range moves to `>=0.15.0 <0.16.0`.
11
+
12
+ ### Changed
13
+
14
+ - 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.
15
+ - `PromptOutcome` gains `withdrawn`: a client that switches over the outcomes must handle it (close the prompt as for `cancelled`).
16
+
17
+ ### Added
18
+
19
+ - `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.
20
+ - `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.
21
+
22
+ ## [0.14.1] - 2026-10-14
23
+
24
+ - 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`.
25
+
8
26
  ## [0.14.0] - 2026-10-14
9
27
 
10
28
  - Release in lockstep with pi-roundtable 0.14.0; no package-specific behavior changes. The core peer range moves to `>=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`, or `cancelled` (the turn stopped). |
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.14.0",
3
+ "version": "0.15.0",
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.14.0 <0.15.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.14.0",
46
+ "pi-roundtable": "0.15.0",
47
47
  "typebox": "1.3.34",
48
48
  "typescript": "7.0.2"
49
49
  }
@@ -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) return Promise.resolve({ outcome: "cancelled" });
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 settle = (outcome: PromptOutcome, answer?: OwnerAnswer) => {
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
- return outcome === "cancelled" ? "cancelled" : "expired";
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
- /** How a prompt closed. */
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 }