pi-roundtable 0.26.0 → 0.26.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 CHANGED
@@ -5,6 +5,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.26.1] - 2026-10-30
9
+
10
+ ### Added
11
+
12
+ - `pi-roundtable-web`: typing `@AI` as the first word of the composer works as the toggle in a room that offers it: the switch shows on while it is there, and the page sends an explicit turn with the token cut from the text. Pasted text, a mention later in the text and an address like `a@ai.com` do not count, and the server never reads a message's text for it.
13
+ - `pi-roundtable-web`: internal notes in an agent or group room: an agent or group room that more than one person reads (the host has more than one owner) gets an `@AI` switch on the composer, on by default. Off, the message is an internal note: it is stored in the room's log with its author, shown live to every other owner and in the history with the author's name and picture and an "Internal note" marker (zh-TW has its own wording), and never given to the agent: no turn runs, it is not in the agent's session, and it is not in any channel context. The server says which rooms take notes: `RoomView.notes` is `true` on the agent and group rooms of a host with more than one owner, absent otherwise, so the page does not guess. A note is a `send` frame with `mode: "note"` (the frame the host rooms already use); a stored note carries `RoomMessage.note: true` (additive column `web_room_messages.note`, migration `web-room-messages-note`). A host with one owner behaves exactly as before: no switch, no new field in the room list or the history, and a `note` frame is still refused as `bad_frame`. The fixture takes `FIXTURE_OWNERS=2` to try it with Ada and Bob.
14
+
15
+ ### Fixed
16
+
17
+ - `pi-roundtable-web`: a folder's header no longer turns into an unreadable light fill with white text when the pointer rests on it (on a phone the highlight stuck after a tap), and its title is left-aligned, not centered. The base rule `button:hover:not(:disabled)` out-specified the header's own style; its hover, press and keyboard focus now tint the label and keep its text colour in every look and scheme.
18
+ - `pi-roundtable-web`: a row of the room list that is held and dragged on an iPhone now lifts and reorders. iOS Safari lets a page stop a finger's scroll only if a non-passive `touchmove` listener existed when the finger went down; the lift added its own after the hold, so the first move scrolled the page and the browser cancelled the drag. While a row that can be lifted is on the page, a no-op non-passive `touchmove` listener is now held.
19
+ - `pi-roundtable-web`: the plain room list (no pin and no folder yet) keeps Agents and Groups by latest activity, as the arranged list and the drawer do. Before, it kept the order the server listed them in, so a room you had just talked to did not move up, even after a reload.
20
+
21
+ ### Added
22
+
23
+ - `HoldDescription.onDeclined` (hold answers `{ text, onDeclined }`): called once when the call's approval card is declined, also after the turn stopped waiting, so the plugin that held the call can close what it opened for it, such as its own approval notice. Not called for an approval, an expiry, a withdrawal through `supersede` or a message that does not confirm the call; what it throws is ignored. See [Hearing that the card was declined](docs/plugins.md#hearing-that-the-card-was-declined).
24
+ - `pi-roundtable-web`: `HostCard.keep`: a card the host still wants (a ticket's pinned card while the ticket is open) survives its kind's `retention.textDays` sweep for as long as `card` or `updateCard` passes `keep: true`; `updateCard` without it makes the card ordinary again. Adds the migration `web-host-cards-keep` (a `keep` column).
25
+ - `conversations.keep(record)` (config; `ResolvedConfig.conversations.keep`): asked for each conversation the retention sweep would delete after its kind's `keepDays`; `true` keeps it until the next sweep, so a host can hold a conversation it still wants, such as a support conversation whose ticket is open. A `keep` that throws keeps the conversation and is logged. `ConversationSweep` gains `kept`. See [Conversation retention](docs/plugins.md#conversation-retention-conversationskeepdays).
26
+ - `pi-roundtable-web`: `WebApp.rooms.context(room, { limit?, messageChars? })`: a host room's recent messages as the channel-context block `CHANNEL_CONTEXT.read` gives a turn, for a host that starts a turn from a room's `send` hook and has no addressing message. It marks nothing handed over, so a retry reads the same.
27
+ - `pi-roundtable-web`: `auth.onSessionVerified(principalId)` for `oidc`: called when a request or an open socket proves a live session, at most once per session every five minutes, so a host can count a person who only has the app open as present. It is not awaited; an error it throws is logged and never fails the request.
28
+
29
+ ### Changed
30
+
31
+ - `pi-roundtable-web`: an ordinary folder is a light section header (chevron, name, count, ⋯), left-aligned like the Home and Pinned labels, not the boxed grey card, which stays for the archive. A folded folder shows a small overlapping stack of the pictures of its first four rooms and, when it holds more, how many more (`+2`).
32
+
8
33
  ## [0.26.0] - 2026-10-30
9
34
 
10
35
  ### Added
package/docs/plugins.md CHANGED
@@ -1006,6 +1006,22 @@ A rule whose verdict depends on the input, such as one that holds only a `delete
1006
1006
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
1007
1007
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
1008
1008
 
1009
+ #### Hearing that the card was declined
1010
+
1011
+ A hold that opened something of its own for the call, such as a notice that asks for the approval, closes it when the owner declines the card by answering `{ text, onDeclined }` (`HoldDescription.onDeclined`, beside `supersede`; a tool's `hold` and a rule's `describeInTurn` both may):
1012
+
1013
+ ```ts
1014
+ hold: (args) => {
1015
+ const notice = openNotice(`Send ${args.to} the quote?`);
1016
+ return { text: `send ${args.to} the quote`, onDeclined: () => notice.close() };
1017
+ },
1018
+ ```
1019
+
1020
+ `onDeclined` is called once when the call's approval card is declined, whether the turn was still waiting on it or the owner declined after the turn stopped waiting (the call is refused either way, as before).
1021
+ It is not called when the card is approved, goes unanswered or expires, is cancelled with the turn, or when the plugin withdrew the call with `supersede` first (the plugin did that itself), and a held call that a message does not confirm is simply dropped without it.
1022
+ A hook belongs to the process that holds the card: a restart loses it with the card, so a plugin that must clean up after a restart does so when the hold describes the repeated call again.
1023
+ What the hook throws or rejects with is ignored, and so is anything but a function.
1024
+
1009
1025
  #### A hold that makes its card the speaker's alone
1010
1026
 
1011
1027
  By default the speaker whose turn made a held call may answer its card, and in a shared conversation so may every owner. A hold that wants only the speaker to press, such as one whose tool books something under the speaker's name, answers `{ text, approval: "speaker" }` (`HoldDescription.approval`, beside `copy` and `supersede`; a tool's `hold` and a rule's `describeInTurn` both may):
@@ -2223,6 +2239,17 @@ The key is a conversation's kind (`ConversationRecord.kind`) and the value a who
2223
2239
  A sweep runs when the host starts and once a day after: for each recorded conversation of a listed kind past its days, it calls `ConversationPort.deleteConversation`, which its claim carries out, and then forgets the record (`ConversationRegistry.remove`, optional on a hand-built registry).
2224
2240
  A conversation with a turn running or waiting (`"busy"`) is skipped and tried again at the next sweep; one whose claim cannot delete it is logged and kept.
2225
2241
  A kind not listed, and a host without the key, keeps its conversations as before.
2242
+
2243
+ A conversation can be wanted for longer than its kind's days, such as a support conversation whose ticket is still open.
2244
+ `conversations.keep(record)` is asked for each conversation past its days, before it is deleted; answering `true` keeps it for now, and it is asked again at the next sweep, so the conversation goes once the host stops wanting it.
2245
+ A `keep` that throws or rejects keeps the conversation for this sweep and is logged.
2246
+
2247
+ ```ts
2248
+ conversations: {
2249
+ keepDays: { support: 365 },
2250
+ keep: async (conversation) => (await tickets.isOpen(conversation.key)),
2251
+ },
2252
+ ```
2226
2253
  The agents' own channels are not recorded conversations and are not touched.
2227
2254
 
2228
2255
  ### `piPackages`: Pi extensions every session loads
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
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",
@@ -271,7 +271,10 @@ export interface ResolvedConfig extends ResolvedLimits {
271
271
  /** Absent `retention`: used files live as long as their conversation. */
272
272
  attachments: AttachmentsConfig;
273
273
  /** Days to keep each conversation kind after its last activity; a kind not listed is kept. */
274
- conversations: { keepDays: Readonly<Record<string, number>> };
274
+ conversations: {
275
+ keepDays: Readonly<Record<string, number>>;
276
+ keep?: ConversationsConfig["keep"];
277
+ };
275
278
  agentDir: string;
276
279
  model: ModelRef;
277
280
  thinking: ThinkingLevel;
@@ -373,7 +376,12 @@ export function resolveConfig(input: unknown): ResolvedConfig {
373
376
  attachments: config.attachments?.retention
374
377
  ? { retention: config.attachments.retention }
375
378
  : {},
376
- conversations: { keepDays: { ...config.conversations?.keepDays } },
379
+ conversations: {
380
+ keepDays: { ...config.conversations?.keepDays },
381
+ ...(config.conversations?.keep
382
+ ? { keep: config.conversations.keep }
383
+ : {}),
384
+ },
377
385
  agentDir: config.agentDir ?? `${config.dataDir}/pi`,
378
386
  model,
379
387
  thinking,
@@ -1,4 +1,5 @@
1
- import { integer, optional, record, shape } from "./schema.ts";
1
+ import type { ConversationRecord } from "../conversations/conversation-registry.ts";
2
+ import { guarded, integer, optional, record, shape } from "./schema.ts";
2
3
 
3
4
  /** The configuration's `conversations`: what the host does with the conversations it has recorded. */
4
5
  export interface ConversationsConfig {
@@ -10,6 +11,15 @@ export interface ConversationsConfig {
10
11
  * which is what a host without this key does.
11
12
  */
12
13
  keepDays?: Readonly<Record<string, number>>;
14
+ /**
15
+ * Asked for each conversation the sweep would delete, after its kind's `keepDays` ran out:
16
+ * answer `true` to keep it for now, such as a support conversation whose ticket is still open.
17
+ * It is asked again at the next sweep, so a conversation kept today is deleted once the host
18
+ * stops wanting it. A function that throws or rejects keeps the conversation for this sweep and
19
+ * is logged. Without it
20
+ * every conversation past its days is deleted.
21
+ */
22
+ keep?: (conversation: ConversationRecord) => boolean | Promise<boolean>;
13
23
  }
14
24
 
15
25
  /** The longest keep the configuration accepts, in days: a hundred years. */
@@ -17,5 +27,14 @@ const MAX_DAYS = 36_500;
17
27
 
18
28
  /** The `conversations` key's shape. */
19
29
  export const conversationsShape = optional(
20
- shape({ keepDays: optional(record(integer(1, MAX_DAYS))) }),
30
+ shape({
31
+ keepDays: optional(record(integer(1, MAX_DAYS))),
32
+ keep: optional(
33
+ guarded(
34
+ "a function from a conversation record to true to keep it",
35
+ (value): value is NonNullable<ConversationsConfig["keep"]> =>
36
+ typeof value === "function",
37
+ ),
38
+ ),
39
+ }),
21
40
  );
@@ -1,7 +1,10 @@
1
1
  import type { ConversationPort } from "../contract/channels.ts";
2
2
  import type { Logger } from "../log.ts";
3
3
  import type { Service } from "../plugin.ts";
4
- import type { ConversationRegistry } from "./conversation-registry.ts";
4
+ import type {
5
+ ConversationRecord,
6
+ ConversationRegistry,
7
+ } from "./conversation-registry.ts";
5
8
 
6
9
  const DAY_MS = 24 * 60 * 60_000;
7
10
 
@@ -12,6 +15,8 @@ export interface ConversationRetentionOptions {
12
15
  conversations: Pick<ConversationPort, "deleteConversation">;
13
16
  /** Days to keep each kind after its last activity; a kind not listed is kept. */
14
17
  keepDays: Readonly<Record<string, number>>;
18
+ /** True keeps a conversation past its days for this sweep; a throw keeps it too and is logged. */
19
+ keep?: (record: ConversationRecord) => boolean | Promise<boolean>;
15
20
  now: () => Date;
16
21
  logger: Logger;
17
22
  }
@@ -21,8 +26,10 @@ export interface ConversationSweep {
21
26
  deleted: number;
22
27
  /** A turn was running or waiting: tried again at the next sweep. */
23
28
  busy: number;
24
- /** Its claim could not delete it: logged, and tried again at the next sweep. */
29
+ /** Its claim could not delete it, or `keep` threw: logged, and tried again at the next sweep. */
25
30
  failed: number;
31
+ /** `keep` wanted it: asked again at the next sweep. */
32
+ kept: number;
26
33
  }
27
34
 
28
35
  /**
@@ -33,8 +40,13 @@ export interface ConversationSweep {
33
40
  export async function sweepConversations(
34
41
  options: ConversationRetentionOptions,
35
42
  ): Promise<ConversationSweep> {
36
- const { registry, conversations, keepDays, now, logger } = options;
37
- const result: ConversationSweep = { deleted: 0, busy: 0, failed: 0 };
43
+ const { registry, conversations, keepDays, keep, now, logger } = options;
44
+ const result: ConversationSweep = {
45
+ deleted: 0,
46
+ busy: 0,
47
+ failed: 0,
48
+ kept: 0,
49
+ };
38
50
  const at = now().getTime();
39
51
  for (const record of await registry.list()) {
40
52
  const days = keepDays[record.kind];
@@ -44,6 +56,10 @@ export async function sweepConversations(
44
56
  )
45
57
  continue;
46
58
  try {
59
+ if (keep && (await keep(record))) {
60
+ result.kept += 1;
61
+ continue;
62
+ }
47
63
  const outcome = await conversations.deleteConversation(record.key);
48
64
  if (outcome === "busy") {
49
65
  result.busy += 1;
@@ -55,7 +71,7 @@ export async function sweepConversations(
55
71
  result.failed += 1;
56
72
  logger.warn(
57
73
  { channel: record.key, kind: record.kind, err: error },
58
- "conversation past its keepDays not deleted",
74
+ "conversation past its keepDays not deleted (keep or its claim failed)",
59
75
  );
60
76
  }
61
77
  }
@@ -77,7 +93,7 @@ export function conversationRetentionService(
77
93
  const sweep = async (): Promise<void> => {
78
94
  try {
79
95
  const result = await sweepConversations(options);
80
- if (result.deleted + result.busy + result.failed > 0)
96
+ if (result.deleted + result.busy + result.failed + result.kept > 0)
81
97
  options.logger.info(result, "conversations past their keepDays swept");
82
98
  } catch (error) {
83
99
  options.logger.warn(
@@ -2,7 +2,10 @@ import type { SQL } from "bun";
2
2
  import type { RoundtablePlugin } from "../plugin.ts";
3
3
  import { CONVERSATIONS } from "../services.ts";
4
4
  import type { ConversationRegistry } from "./conversation-registry.ts";
5
- import { conversationRetentionService } from "./conversation-retention.ts";
5
+ import {
6
+ type ConversationRetentionOptions,
7
+ conversationRetentionService,
8
+ } from "./conversation-retention.ts";
6
9
  import { PgConversationRegistry } from "./conversation-store.ts";
7
10
 
8
11
  /** The name of the conversation registry's plugin, as the migration ledger names it. */
@@ -19,6 +22,8 @@ export function conversationsPlugin(
19
22
  PgConversationRegistry.attach(sql),
20
23
  /** Days to keep each conversation kind after its last activity (`conversations.keepDays`); none = keep them all. */
21
24
  keepDays: Readonly<Record<string, number>> = {},
25
+ /** Asked before a conversation past its days is deleted (`conversations.keep`); true keeps it. */
26
+ keep?: ConversationRetentionOptions["keep"],
22
27
  ): RoundtablePlugin {
23
28
  return {
24
29
  name: CONVERSATIONS_PLUGIN,
@@ -35,6 +40,7 @@ export function conversationsPlugin(
35
40
  registry,
36
41
  conversations,
37
42
  keepDays,
43
+ ...(keep ? { keep } : {}),
38
44
  now: () => env.now(),
39
45
  logger,
40
46
  }),
@@ -372,7 +372,11 @@ export async function defineRoundtable(
372
372
  }),
373
373
  ...(assembly?.admin ?? []),
374
374
  ...(agentTeam?.skills ?? []),
375
- conversationsPlugin(undefined, config.conversations.keepDays),
375
+ conversationsPlugin(
376
+ undefined,
377
+ config.conversations.keepDays,
378
+ config.conversations.keep,
379
+ ),
376
380
  modesPlugin(),
377
381
  runtimePlugin({
378
382
  owner,
package/src/core/holds.ts CHANGED
@@ -47,6 +47,14 @@ export interface HoldContext {
47
47
  *
48
48
  * `classes` are the hold classes of the call, for a rule that describes it in a turn (see
49
49
  * `HoldRule.holdClasses`); a rule that leaves them out leaves its call unclassed.
50
+ *
51
+ * `onDeclined` is called once when the call's approval card is declined, whether the turn was
52
+ * still waiting on it or the owner declined after the turn stopped waiting, so a plugin can close
53
+ * what it opened for the call, such as its own notice that asks for the approval. It is not
54
+ * called when the call is withdrawn through `supersede` (the plugin did that itself), approved,
55
+ * left unanswered or expired, or dropped by a message that does not confirm it: only a declined
56
+ * card calls it, in the process that holds the card (a restart loses it with the card). It
57
+ * must not throw; whatever it throws or rejects with is ignored, and the call is refused either way.
50
58
  */
51
59
  export type HoldDescription =
52
60
  | string
@@ -56,6 +64,7 @@ export type HoldDescription =
56
64
  supersede?: AbortSignal;
57
65
  approval?: "speaker";
58
66
  classes?: readonly string[];
67
+ onDeclined?: () => void | Promise<void>;
59
68
  };
60
69
 
61
70
  /**
@@ -20,7 +20,7 @@ import {
20
20
  approverOf,
21
21
  askSpeaker,
22
22
  awaitingText,
23
- declinedText,
23
+ declinedRefusal,
24
24
  type GateDecision,
25
25
  type Needing,
26
26
  namesOf,
@@ -337,7 +337,7 @@ export class ConfirmationGate {
337
337
  wait: {
338
338
  // A late answer on the card: the text of the turn that tells the model, and the release it earned.
339
339
  late: (late) => {
340
- const outcome = lateOutcome(call, late, watch);
340
+ const outcome = lateOutcome(call, late, watch, needing.onDeclined);
341
341
  if (outcome?.release) this.#lateApproved.push(outcome.release);
342
342
  return outcome?.text;
343
343
  },
@@ -372,13 +372,8 @@ export class ConfirmationGate {
372
372
  reason: awaitingText(this.#addressee, call),
373
373
  refused: "pending",
374
374
  };
375
- if (answer === "declined") {
376
- watch?.end();
377
- return {
378
- reason: declinedText(this.#addressee, call),
379
- refused: "declined",
380
- };
381
- }
375
+ if (answer === "declined")
376
+ return declinedRefusal(this.#addressee, call, watch, needing.onDeclined);
382
377
  if (answer === "cancelled") {
383
378
  watch?.end();
384
379
  return { reason: "The turn was stopped." };
@@ -449,6 +444,7 @@ export class ConfirmationGate {
449
444
  ...(judged.copy ? { copy: judged.copy } : {}),
450
445
  ...(judged.supersede ? { supersede: judged.supersede } : {}),
451
446
  ...(judged.speakerOnly ? { speakerOnly: judged.speakerOnly } : {}),
447
+ ...(judged.onDeclined ? { onDeclined: judged.onDeclined } : {}),
452
448
  };
453
449
  }
454
450
 
@@ -37,6 +37,8 @@ export type Needing =
37
37
  supersede?: AbortSignal;
38
38
  /** The card is the speaker's alone; see `HoldDescription`. */
39
39
  speakerOnly?: true;
40
+ /** Called when the call's card is declined; see `HoldDescription`. */
41
+ onDeclined?: () => void | Promise<void>;
40
42
  }
41
43
  | { released: Release };
42
44
 
@@ -76,6 +78,36 @@ export function declinedText(addressee: OwnerIdentity, call: HeldCall): string {
76
78
  return `${o.name} declined this on its approval card: it would ${call.action}. Do not retry it; go on without it, or ask ${o.him} what ${o.he} wants instead.`;
77
79
  }
78
80
 
81
+ /**
82
+ * Tells the plugin that held a call that its card was declined (`HoldDescription.onDeclined`).
83
+ * The call is refused whatever the plugin does, so what its hook throws or rejects with is ignored.
84
+ */
85
+ export function notifyDeclined(
86
+ onDeclined: (() => void | Promise<void>) | undefined,
87
+ ): void {
88
+ if (!onDeclined) return;
89
+ try {
90
+ Promise.resolve(onDeclined()).catch(() => undefined);
91
+ } catch {
92
+ // A hook that throws changes nothing: the call is refused already.
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The refusal of a call declined on its card the turn was still waiting on: ends the plugin's
98
+ * watch of the call, tells it through `onDeclined`, and gives the turn the text and the reason.
99
+ */
100
+ export function declinedRefusal(
101
+ addressee: OwnerIdentity,
102
+ call: HeldCall,
103
+ watch: { end(): void } | undefined,
104
+ onDeclined: (() => void | Promise<void>) | undefined,
105
+ ): { reason: string; refused: "declined" } {
106
+ watch?.end();
107
+ notifyDeclined(onDeclined);
108
+ return { reason: declinedText(addressee, call), refused: "declined" };
109
+ }
110
+
79
111
  /** The text for a held call that the speaker's own message confirms. */
80
112
  export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
81
113
  const o = ownerWords(addressee);
@@ -36,6 +36,8 @@ export interface Judgment {
36
36
  speakerOnly?: true;
37
37
  /** The hold classes of the call; see `HoldRule.holdClasses`. Absent when the holds gave none. */
38
38
  classes?: readonly string[] | undefined;
39
+ /** Called when the call's card is declined; see `HoldDescription`. */
40
+ onDeclined?: () => void | Promise<void>;
39
41
  }
40
42
 
41
43
  export type Judged = Judgment | undefined | typeof STOPPED;
@@ -59,11 +61,14 @@ function isSignal(value: unknown): value is AbortSignal {
59
61
  export function described(
60
62
  answer: HoldDescription,
61
63
  tool: string,
62
- ): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly" | "classes"> {
64
+ ): Pick<
65
+ Judgment,
66
+ "action" | "copy" | "supersede" | "speakerOnly" | "classes" | "onDeclined"
67
+ > {
63
68
  if (typeof answer === "string") return { action: answer };
64
69
  if (typeof answer !== "object" || answer === null)
65
70
  return { action: messages().holdGeneric(tool) };
66
- const { text, copy, supersede, approval, classes } = answer;
71
+ const { text, copy, supersede, approval, classes, onDeclined } = answer;
67
72
  if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
68
73
  const named = Array.isArray(classes)
69
74
  ? classes.filter((id): id is string => typeof id === "string" && id !== "")
@@ -72,6 +77,7 @@ export function described(
72
77
  ...(isSignal(supersede) ? { supersede } : {}),
73
78
  ...(approval === "speaker" ? { speakerOnly: true as const } : {}),
74
79
  ...(named.length > 0 ? { classes: named } : {}),
80
+ ...(typeof onDeclined === "function" ? { onDeclined } : {}),
75
81
  };
76
82
  if (
77
83
  !copy ||
@@ -142,6 +148,7 @@ export function joinApprovers(
142
148
  supersede,
143
149
  speakerOnly,
144
150
  classes,
151
+ onDeclined,
145
152
  } = described(action, tool);
146
153
  const joined = (approvers: readonly string[] | undefined): Judgment => ({
147
154
  action: text,
@@ -149,6 +156,7 @@ export function joinApprovers(
149
156
  ...(supersede ? { supersede } : {}),
150
157
  ...(speakerOnly ? { speakerOnly } : {}),
151
158
  ...(classes ? { classes } : {}),
159
+ ...(onDeclined ? { onDeclined } : {}),
152
160
  ...(approvers ? { approvers } : {}),
153
161
  });
154
162
  const controller = new AbortController();
@@ -1,6 +1,6 @@
1
1
  import type { HeldCall } from "../../domain/conversation.ts";
2
2
  import type { LateAnswer } from "../../interactions/prompts.ts";
3
- import type { Release } from "./gate-refusals.ts";
3
+ import { notifyDeclined, type Release } from "./gate-refusals.ts";
4
4
  import { namedApprover } from "./held-calls.ts";
5
5
  import type { Watch } from "./withdrawals.ts";
6
6
 
@@ -8,12 +8,14 @@ import type { Watch } from "./withdrawals.ts";
8
8
  * What a card answered after its turn stopped waiting comes to: the text of the turn that tells
9
9
  * the model, and, for an approval that stands, the release that lets the identical call run once
10
10
  * in whichever turn makes it next. Undefined when the answer changes nothing: a question's, a
11
- * card its plugin withdrew, or an approval by someone the call does not name.
11
+ * card its plugin withdrew, or an approval by someone the call does not name. A card declined
12
+ * tells the plugin through `onDeclined`.
12
13
  */
13
14
  export function lateOutcome(
14
15
  call: HeldCall,
15
16
  late: LateAnswer,
16
17
  watch: Watch | undefined,
18
+ onDeclined?: () => void | Promise<void>,
17
19
  ): { text: string; release?: Release } | undefined {
18
20
  // A card the plugin withdrew answers nothing, whatever a surface that kept it open reports.
19
21
  if (watch?.withdrawn || late.kind !== "approval") return undefined;
@@ -21,6 +23,7 @@ export function lateOutcome(
21
23
  if (late.approved && !namedApprover(call, late.approvedBy)) return undefined;
22
24
  if (!late.approved) {
23
25
  watch?.end();
26
+ notifyDeclined(onDeclined);
24
27
  return {
25
28
  text: `(${late.by} declined on its card the action you were waiting on: it would ${call.action}. It did not run; do not make the call again. Go on without it, or ask what ${late.by} wants instead.)`,
26
29
  };