pi-roundtable 0.22.2 → 0.22.4

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,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.22.4] - 2026-10-25
9
+
10
+ ### Security
11
+
12
+ - The Bun and Node runtimes the repository pins carry the Brotli decoder fix (CVE-2026-111497, GHSA-9v8h-qqgm-4m35): CI and the publish workflow run Bun 1.4.4 (was 1.4.2), the sandbox worker images build on `oven/bun:1.4.4` and the browser image on `node:24.22.1-bookworm-slim` (was the floating `node:24-bookworm-slim`). Run pi-roundtable on Bun 1.4.4 or newer, and rebuild the sandbox and browser images. The `engines` floors are unchanged.
13
+
14
+ ## [0.22.3] - 2026-10-23
15
+
16
+ ### Changed
17
+
18
+ - Question cards can be cancelled (`pi-roundtable`). Every Discord `ask_user` card, single or several questions, has a **Cancel** button beside Submit, for those who may answer it. Pressed while the turn still waits, `Prompts.ask` and `Prompts.questionnaire` resolve the new `"declined"`, and the `ask_user` result tells the model the owner cancelled the question without answering, not to ask it again, and to continue without the answer or end the turn if it cannot. Pressed after the grace period, it only closes the card and starts no turn. The card shows "❌ Cancelled, not answered." with its controls disabled. A surface that implements `Prompts` need not return `"declined"`; code that reads `ask` or `questionnaire` results should handle it. See [a prompt that outlives its turn](docs/plugins.md#a-prompt-that-outlives-its-turn-promptwait).
19
+
8
20
  ## [0.22.2] - 2026-10-21
9
21
 
10
22
  ### Fixed
package/docs/plugins.md CHANGED
@@ -2730,6 +2730,7 @@ A coding worker's cards give no `wait`, since nothing could resume a worker: it
2730
2730
 
2731
2731
  On Discord the grace period is `discord.cardGraceSeconds` (default 120), and the cards carry no countdown.
2732
2732
  A card answered late is edited to show the answer with its controls disabled, as one answered in time is; a second press finds it answered.
2733
+ Every question card, single or several questions, has a **Cancel** button beside its answer controls, for the same audience as the answers. Pressed while the turn still waits, `ask` and `questionnaire` resolve `"declined"`, and `ask_user` tells the model the person cancelled the question without answering, not to ask it again, and to go on without the answer or end its turn if it cannot. Pressed after the turn stopped waiting, it only closes the card: no turn starts and nothing stays pending. Either way the card shows "❌ Cancelled, not answered." with its controls disabled, and a second press finds it ended.
2733
2734
  Open cards live in memory: after a restart, pressing one replies, to the presser alone, that it is no longer valid and to ask again.
2734
2735
 
2735
2736
  #### Several questions on one card: `questionnaire` and `AskSupport`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@ import type {
11
11
  import type { CardAudiences } from "./card-audience.ts";
12
12
  import type { QuestionnaireForm } from "./owner-card-questionnaire.ts";
13
13
  import { newDraft, questionnaireCard } from "./owner-card-questionnaire.ts";
14
- import { askRows, previewsRow } from "./owner-card-rows.ts";
14
+ import { askRows, cancelRow, previewsRow } from "./owner-card-rows.ts";
15
15
  import type { PostCard } from "./owner-card-types.ts";
16
16
 
17
17
  /** What the Discord card shows of a questionnaire: four questions, previews, and a note. */
@@ -34,7 +34,7 @@ export function askPrompts(args: {
34
34
  ask: async (title, question, signal, wait) => {
35
35
  const audience = await audiences.question(scope);
36
36
  if (!audience) return undefined;
37
- return post<OwnerAnswer | "pending" | undefined>(
37
+ return post<OwnerAnswer | "pending" | "declined" | undefined>(
38
38
  channelId,
39
39
  signal,
40
40
  undefined,
@@ -57,7 +57,11 @@ export function askPrompts(args: {
57
57
  title,
58
58
  sections: [question.question],
59
59
  question,
60
- rows: (id, disabled) => askRows(id, question, disabled),
60
+ declined: "declined",
61
+ rows: (id, disabled) => [
62
+ ...askRows(id, question, disabled),
63
+ cancelRow(id, disabled),
64
+ ],
61
65
  footer: messages().cardQuestionFooter,
62
66
  },
63
67
  );
@@ -68,40 +72,39 @@ export function askPrompts(args: {
68
72
  const [only] = questionnaire.questions;
69
73
  if (only && questionnaire.questions.length === 1) {
70
74
  // One question is the 0.19 card, answered by one click, with a Previews button.
71
- const answer = await post<OwnerAnswer | "pending" | undefined>(
72
- channelId,
73
- signal,
74
- undefined,
75
- undefined,
76
- {
77
- audience,
78
- ...(wait
79
- ? {
80
- wait: {
81
- pending: "pending",
82
- late: (value, by) =>
83
- wait.late({
84
- kind: "questionnaire",
85
- answer: { answers: [value as OwnerAnswer] },
86
- by: by.user.name,
87
- }),
88
- },
89
- }
90
- : {}),
91
- title,
92
- sections: [only.question],
93
- question: only,
94
- previews: questionnaire,
95
- rows: (id, disabled) => [
96
- ...askRows(id, only, disabled),
97
- ...(only.options.some((o) => o.preview !== undefined)
98
- ? [previewsRow(id, disabled)]
99
- : []),
100
- ],
101
- footer: messages().cardQuestionFooter,
102
- },
103
- );
104
- return answer === "pending" || answer === undefined
75
+ const answer = await post<
76
+ OwnerAnswer | "pending" | "declined" | undefined
77
+ >(channelId, signal, undefined, undefined, {
78
+ audience,
79
+ ...(wait
80
+ ? {
81
+ wait: {
82
+ pending: "pending",
83
+ late: (value, by) =>
84
+ wait.late({
85
+ kind: "questionnaire",
86
+ answer: { answers: [value as OwnerAnswer] },
87
+ by: by.user.name,
88
+ }),
89
+ },
90
+ }
91
+ : {}),
92
+ title,
93
+ sections: [only.question],
94
+ question: only,
95
+ previews: questionnaire,
96
+ declined: "declined",
97
+ rows: (id, disabled) => [
98
+ ...askRows(id, only, disabled),
99
+ only.options.some((o) => o.preview !== undefined)
100
+ ? previewsRow(id, disabled)
101
+ : cancelRow(id, disabled),
102
+ ],
103
+ footer: messages().cardQuestionFooter,
104
+ });
105
+ return answer === "pending" ||
106
+ answer === "declined" ||
107
+ answer === undefined
105
108
  ? answer
106
109
  : { answers: [answer] };
107
110
  }
@@ -109,7 +112,7 @@ export function askPrompts(args: {
109
112
  questionnaire,
110
113
  draft: newDraft(questionnaire.questions.length),
111
114
  };
112
- return post<QuestionnaireAnswer | "pending" | undefined>(
115
+ return post<QuestionnaireAnswer | "pending" | "declined" | undefined>(
113
116
  channelId,
114
117
  signal,
115
118
  undefined,
@@ -132,6 +135,7 @@ export function askPrompts(args: {
132
135
  title,
133
136
  sections: [],
134
137
  form,
138
+ declined: "declined",
135
139
  build: (id, footer, disabled) =>
136
140
  questionnaireCard({
137
141
  id,
@@ -19,7 +19,7 @@ import {
19
19
  type Questionnaire,
20
20
  type QuestionnaireAnswer,
21
21
  } from "../interactions/questionnaire.ts";
22
- import { CARD_PREFIX } from "./owner-card-rows.ts";
22
+ import { CARD_PREFIX, cancelButton } from "./owner-card-rows.ts";
23
23
  import type { CardPayload } from "./owner-card-types.ts";
24
24
  import { ephemeralPanel, ownerPanels, plain } from "./owner-panel.ts";
25
25
  import { clip } from "./utf16.ts";
@@ -127,6 +127,7 @@ function buttons(id: string, form: QuestionnaireForm, disabled: boolean) {
127
127
  .setDisabled(disabled);
128
128
  row.addComponents(
129
129
  button("submit", messages().cardSubmitLabel, ButtonStyle.Primary),
130
+ cancelButton(id, disabled),
130
131
  button("notes", messages().cardWriteLabel, ButtonStyle.Secondary),
131
132
  );
132
133
  if (questions.some((q) => q.options.some((o) => o.preview !== undefined)))
@@ -85,7 +85,7 @@ export function askRows(
85
85
  ];
86
86
  }
87
87
 
88
- /** The Previews button of a single-question card: it shows the options' previews and answers nothing. */
88
+ /** The Previews button of a single-question card, which answers nothing, beside its Cancel button. */
89
89
  export function previewsRow(id: string, disabled: boolean) {
90
90
  return new ActionRowBuilder<ButtonBuilder>().addComponents(
91
91
  new ButtonBuilder()
@@ -93,6 +93,24 @@ export function previewsRow(id: string, disabled: boolean) {
93
93
  .setLabel(messages().cardPreviewsLabel)
94
94
  .setStyle(ButtonStyle.Secondary)
95
95
  .setDisabled(disabled),
96
+ cancelButton(id, disabled),
97
+ );
98
+ }
99
+
100
+ /** A question card's Cancel button: the question is declined, not answered. */
101
+ export function cancelButton(id: string, disabled: boolean): ButtonBuilder {
102
+ return new ButtonBuilder()
103
+ .setCustomId(`${CARD_PREFIX}${id}:cancel`)
104
+ .setLabel(messages().cardCancelLabel)
105
+ .setEmoji("❌")
106
+ .setStyle(ButtonStyle.Secondary)
107
+ .setDisabled(disabled);
108
+ }
109
+
110
+ /** A single-question card's row with only its Cancel button. */
111
+ export function cancelRow(id: string, disabled: boolean) {
112
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
113
+ cancelButton(id, disabled),
96
114
  );
97
115
  }
98
116
 
@@ -72,6 +72,12 @@ export interface OpenCard {
72
72
  * already, answered by another press or withdrawn by its plugin, and this answer changed nothing.
73
73
  */
74
74
  end(value: unknown, by: Answerer): boolean;
75
+ /**
76
+ * Ends a question card declined, its Cancel button: a turn still waiting gets `declined`, and
77
+ * a card whose turn stopped waiting only closes, starting no turn. False when the card had
78
+ * ended already or is not a question.
79
+ */
80
+ decline(): boolean;
75
81
  question?: OwnerQuestion;
76
82
  /** A questionnaire card's questions and its shared draft. */
77
83
  form?: QuestionnaireForm;
@@ -99,6 +105,8 @@ export interface CardSpec<T> {
99
105
  question?: OwnerQuestion;
100
106
  form?: QuestionnaireForm;
101
107
  previews?: Questionnaire;
108
+ /** What a question card resolves with when it is cancelled; without it the card has no Cancel. */
109
+ declined?: T;
102
110
  /** Draws the card itself, as a questionnaire does; without it the card is the title, sections and rows. */
103
111
  build?(id: string, footer: string, disabled: boolean): CardPayload;
104
112
  rows?(id: string, disabled: boolean): Rows;
@@ -295,6 +295,12 @@ export class OwnerCards implements InteractionModule {
295
295
  }
296
296
  return true;
297
297
  },
298
+ decline: () => {
299
+ if (!this.#open.has(id) || !("declined" in card)) return false;
300
+ // After the grace period settling only closes the card: no turn starts.
301
+ settle(card.declined as T, undefined);
302
+ return true;
303
+ },
298
304
  ...(card.question ? { question: card.question } : {}),
299
305
  ...(card.form ? { form: card.form } : {}),
300
306
  ...(card.previews ? { previews: card.previews } : {}),
@@ -428,6 +434,18 @@ export class OwnerCards implements InteractionModule {
428
434
  );
429
435
  return true;
430
436
  }
437
+ if (interaction.isButton() && action === "cancel") {
438
+ // Another press, a stop or a withdrawal ended it while this one was being checked.
439
+ if (!card.decline()) {
440
+ await interaction.reply({
441
+ content: messages().cardInactive,
442
+ flags: MessageFlags.Ephemeral,
443
+ });
444
+ return true;
445
+ }
446
+ await interaction.update(card.view(messages().cardQuestionCancelled));
447
+ return true;
448
+ }
431
449
  if (card.form) await answerForm(card, id, action, interaction);
432
450
  else await answerSingle(card, id, action, interaction, by);
433
451
  return true;
@@ -34,6 +34,7 @@ export function cardsEn(ctx: CatalogContext) {
34
34
  cardInactive: `This card is no longer valid (${ctx.assistant} restarted since it was posted, or it was already answered or withdrawn); ask again, or reply in text.`,
35
35
  cardApproved: "✅ Approved to run.",
36
36
  cardDeclined: "❌ Cancelled.",
37
+ cardQuestionCancelled: "❌ Cancelled, not answered.",
37
38
  cardAnswerLabel: "Answer…",
38
39
  cardOtherLabel: "Other…",
39
40
  cardOtherDescription: "Write your own answer",
@@ -81,6 +82,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
81
82
  cardInactive: `這張卡片已經失效(${ctx.assistant} 在它發出後重新啟動過,或它已經回答過或被撤回);請再問一次,或直接打字回覆。`,
82
83
  cardApproved: "✅ 已核准執行。",
83
84
  cardDeclined: "❌ 已取消。",
85
+ cardQuestionCancelled: "❌ 已取消,沒有回答。",
84
86
  cardAnswerLabel: "回答…",
85
87
  cardOtherLabel: "其他…",
86
88
  cardOtherDescription: "自己寫答案",
@@ -248,15 +248,17 @@ export interface Prompts {
248
248
  options?: ApprovalOptions,
249
249
  ): Promise<Approval>;
250
250
  /**
251
- * Asks a question; undefined when no one answered it or it was cancelled, and `pending`, only
252
- * with a `wait`, when the turn stopped waiting and the question stays open.
251
+ * Asks a question; undefined when no one answered it or it was cancelled, `declined` when the
252
+ * one asked cancelled the question on its card instead of answering, and `pending`, only with
253
+ * a `wait`, when the turn stopped waiting and the question stays open. A question cancelled
254
+ * after the turn stopped waiting only closes: it starts no turn.
253
255
  */
254
256
  ask(
255
257
  title: string,
256
258
  question: OwnerQuestion,
257
259
  signal?: AbortSignal,
258
260
  wait?: PromptWait,
259
- ): Promise<OwnerAnswer | "pending" | undefined>;
261
+ ): Promise<OwnerAnswer | "pending" | "declined" | undefined>;
260
262
  /**
261
263
  * What `questionnaire` can show; absent means the 0.19 contract (one question, no previews,
262
264
  * no note), and `ask` is the only way to ask.
@@ -272,7 +274,7 @@ export interface Prompts {
272
274
  questionnaire: Questionnaire,
273
275
  signal?: AbortSignal,
274
276
  wait?: PromptWait,
275
- ): Promise<QuestionnaireAnswer | "pending" | undefined>;
277
+ ): Promise<QuestionnaireAnswer | "pending" | "declined" | undefined>;
276
278
  }
277
279
 
278
280
  /** @deprecated Since 0.9 the prompts are `Prompts`, answered by those their `PromptScope` names; this name goes away in 1.0. */
@@ -61,7 +61,7 @@ export async function askQuestions(
61
61
  );
62
62
  if (n === 1)
63
63
  return answerText(
64
- answer === "pending" || answer === undefined
64
+ answer === "pending" || answer === "declined" || answer === undefined
65
65
  ? answer
66
66
  : (answer.answers[0] ?? { choices: [] }),
67
67
  owner,
@@ -79,11 +79,11 @@ export async function askQuestions(
79
79
  : undefined,
80
80
  });
81
81
  if (n === 1) return answerText(answer, owner);
82
- if (answer === "pending" || answer === undefined)
82
+ if (answer === "pending" || answer === "declined" || answer === undefined)
83
83
  return sequentialText(
84
84
  questionnaire,
85
85
  answers,
86
- answer === "pending" ? "pending" : "unanswered",
86
+ answer ?? "unanswered",
87
87
  owner,
88
88
  answerText(answer, owner),
89
89
  );
@@ -11,10 +11,12 @@ const NAME_CHARS = 120;
11
11
 
12
12
  /** The tool's result: what the owner chose and wrote. */
13
13
  export function answerText(
14
- answer: OwnerAnswer | "pending" | undefined,
14
+ answer: OwnerAnswer | "pending" | "declined" | undefined,
15
15
  owner: OwnerIdentity,
16
16
  ): string {
17
17
  const o = ownerWords(owner);
18
+ if (answer === "declined")
19
+ return `${o.name} cancelled the question on its card without answering. Do not ask the same question again. Continue without the answer, or, if you cannot go on without it, end your turn saying so.`;
18
20
  if (answer === "pending")
19
21
  return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
20
22
  if (!answer)
@@ -72,11 +74,12 @@ function listOf(
72
74
 
73
75
  /** The tool's result for a questionnaire: each question's answer, and the note. */
74
76
  export function questionnaireText(
75
- answer: QuestionnaireAnswer | "pending" | undefined,
77
+ answer: QuestionnaireAnswer | "pending" | "declined" | undefined,
76
78
  questionnaire: Questionnaire,
77
79
  owner: OwnerIdentity,
78
80
  ): string {
79
- if (answer === "pending" || !answer) return answerText(answer, owner);
81
+ if (answer === "pending" || answer === "declined" || !answer)
82
+ return answerText(answer, owner);
80
83
  return [
81
84
  `${owner.name} answered your questions on the card:`,
82
85
  ...listOf(questionnaire, answer),
@@ -102,7 +105,7 @@ export function lateQuestionnaireText(
102
105
  export function sequentialText(
103
106
  asked: Questionnaire,
104
107
  answers: readonly OwnerAnswer[],
105
- stopped: "pending" | "unanswered" | undefined,
108
+ stopped: "pending" | "declined" | "unanswered" | undefined,
106
109
  owner: OwnerIdentity,
107
110
  stoppedText: string,
108
111
  ): string {
@@ -93,7 +93,7 @@ export const QUESTIONNAIRE_CHECKS: readonly QuestionnaireCheck[] = [
93
93
  const given = answerOf(set, found.asks);
94
94
  await answer(id, given, set);
95
95
  const got = await result;
96
- if (got === "pending" || got === undefined)
96
+ if (got === "pending" || got === "declined" || got === undefined)
97
97
  throw new ContractBroken(`the person's answers came back ${got}`);
98
98
  expectThat(
99
99
  JSON.stringify(got.answers.map((a) => a ?? null)) ===