pi-roundtable 0.14.1 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/plugins.md +46 -11
  3. package/examples/fake-surface.ts +9 -5
  4. package/package.json +4 -4
  5. package/src/core/define.ts +15 -0
  6. package/src/core/discord/owner-card-rows.ts +44 -1
  7. package/src/core/discord/owner-card-types.ts +3 -2
  8. package/src/core/discord/owner-cards.ts +57 -58
  9. package/src/core/domain/client.ts +7 -0
  10. package/src/core/domain/progress.ts +8 -2
  11. package/src/core/holds.ts +18 -2
  12. package/src/core/i18n/cards.ts +4 -2
  13. package/src/core/interactions/prompts.ts +43 -1
  14. package/src/core/runtime/conversation-sessions.ts +16 -0
  15. package/src/core/runtime/extensions/approval-card.ts +24 -36
  16. package/src/core/runtime/extensions/approval-fields.ts +212 -0
  17. package/src/core/runtime/extensions/card-ask.ts +74 -0
  18. package/src/core/runtime/extensions/card-text.ts +27 -0
  19. package/src/core/runtime/extensions/confirmation-gate-extension.ts +18 -7
  20. package/src/core/runtime/extensions/confirmation-gate.ts +159 -162
  21. package/src/core/runtime/extensions/gate-refusals.ts +45 -2
  22. package/src/core/runtime/extensions/grants.ts +30 -0
  23. package/src/core/runtime/extensions/held-calls.ts +8 -0
  24. package/src/core/runtime/extensions/hold-describer.ts +99 -0
  25. package/src/core/runtime/extensions/hold-lookups.ts +24 -7
  26. package/src/core/runtime/extensions/late-answer.ts +36 -0
  27. package/src/core/runtime/extensions/withdrawals.ts +95 -0
  28. package/src/core/runtime/session-factory.ts +1 -0
  29. package/src/core/sessions.ts +6 -0
  30. package/src/core/testing/surface-contract.ts +71 -1
  31. package/src/core/tool-turn.ts +3 -0
  32. package/src/index.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+ ### Added
11
+
12
+ - `approvalCard(call, workspace?, limits?, parameters?)` (`pi-roundtable/kit`) takes the tool's parameter JSON Schema, and the confirmation gate passes the schema of the held tool, read from the session's tool list. With it the approval card shows the call as a field list instead of one JSON block: a line `**label**: value` per argument, in the order the schema declares its properties, labelled by the property's `title` or its name, absent arguments left out. An argument the schema does not list follows the listed ones with its name in code format, so it cannot pass for a declared label. Only an argument's own properties count: one named `toString` or `constructor` is shown with its value. A multi-line or long string (a lone `\r`, `\u2028`, `\u2029` and `\u0085` are line breaks too) and any nested value (as compact JSON) go in a fenced block that keeps their line breaks. A value on a line has its markdown, links and `@everyone`/`@here` escaped, as the model writes it; an array of scalars is joined with commas unless an item is empty, shows nothing, or holds a comma and a space or a line break, and then it is JSON; a string that is empty or shows nothing is written out (`""`, `"\u200b"`) so the person sees it is there. The `limits` apply as they did to the JSON: a long string is cut and marked `… [N chars]`, and fields that no longer fit are dropped and listed by label. A tool with no schema, or one that lists no properties, keeps the JSON block, as does a card built without `parameters` (the coding desk's).
13
+ - A plugin can withdraw an approval card it caused. A hold answers `{ text, supersede: signal }` (`HoldDescription.supersede`; a tool's `hold` and a rule's `describeInTurn`), and aborting the signal closes the call's approval card, also after the turn stopped waiting and the card stayed open (the Discord message is edited: controls off, the abort reason as footer when it is a text, else "Withdrawn; nothing will run"; the web chat closes the prompt as `withdrawn` with the reason as `note`), forgets the held call so no later approval runs it (a card's late approval, a confirming message, and the saved held actions included), and tells a turn still waiting that the call was withdrawn (`refused: "withdrawn"` in `HoldRefusal`). Whichever of an approval and the abort comes first decides: an approval answered first runs the call once and the abort changes nothing, and an abort first wins whatever a surface then reports, a call it withdrew is never held again; aborting twice does nothing. Withdrawing removes every identical call (same tool, same canonical input) held for the conversation, whichever hold held it. Nothing stays listening once the call is released, refused, dropped, or its card failed. A signal does not survive a restart, so a withdrawal cannot reach a call held before one; a hold that answers an already aborted `supersede` when the call is repeated refuses it without a card and drops a confirmation of it. New in `pi-roundtable`: `ApprovalOptions.supersede` for surfaces (a surface closes the card when it aborts; a card that arrives with it aborted is not shown) and `withdrawalNote` (`withdrawalNote(signal, fallback)`), the footer from the reason.
14
+ - `ToolTurn.approvedPrompt` (also `SessionContext.approvedPrompt(toolCallId)`): the id of the approval card that released the call a tool's `run` is making, for a tool that must show a client which approval it acts on. It is set when the person approved the held call on its card, in the turn that asked or, for a card answered after that turn stopped waiting, in the later turn whose call that approval releases; it is undefined for a call that was not held, one a message confirmed (also after a restart, which keeps no cards), one a surface released without a card id, and in a host with no turn. A surface reports it through `ApprovalOptions.approvedPrompt(prompt)` (called before `confirm` resolves `approved`; Discord reports the card id, the web chat the `prompt` frame's id) and `LateAnswer.prompt`; a surface whose cards have no stable id leaves both out.
15
+ - `ClientCallOptions.approval?: { prompt: string }`: a tool names the approval a client call acts on, normally `{ prompt: turn.approvedPrompt }`, and the transport hands it to the client beside the call. The web chat writes it into the `client_call` frame as `approval: { prompt }` (a text of 1 to 128 characters, else the call fails); a call without it carries no field.
16
+
17
+ ### Changed
18
+
19
+ - **Breaking for code that matches exhaustively:** `HoldRefusal` (a tool's end in `TurnProgress`) gains `"withdrawn"`, and the web chat's `PromptOutcome` gains `"withdrawn"` (with `prompt_closed.note`). A switch over either needs a case for it; a web chat client closes a withdrawn prompt as it does a cancelled one.
20
+ - **Breaking for third-party surfaces:** `describeSurfaceContract` adds a check that an approval its plugin withdraws (`ApprovalOptions.supersede`) resolves `cancelled` and closes, that one answered first stays answered, and that one withdrawn before it opens is not shown. A surface with approval cards that does not implement `supersede` fails it; the gate still refuses a withdrawn call whatever such a surface reports.
21
+ - The web chat's approval prompt `message`, and the text of the Discord card, show a held call as fields rather than a JSON block when the tool's parameter schema is known (see Added). `approval.input` and `ApprovalDetails` are unchanged.
22
+ - `@babel/parser` and `@babel/types` move to Babel 8 (8.0.7 and 8.0.6), which read a schedule's precheck script and the files the boundary check scans. Import attributes (`with { type: "json" }`) are on by default there, and `importAttributes` is no longer a plugin name. Babel 8 packages need Node 22.18 or newer, or Bun.
23
+
8
24
  ## [0.14.1] - 2026-10-14
9
25
 
10
26
  ### Fixed
package/docs/plugins.md CHANGED
@@ -627,7 +627,7 @@ Throw `ToolRefusal` for a call the model should correct; any other error fails t
627
627
 
628
628
  Some transports carry a client that can do something for a tool: the web chat's browser extension can read the page the person has open.
629
629
  `turn.client` is that client's `ClientPort` when the turn's transport provides one and the client declared it, and undefined otherwise (a Discord message, a schedule, an agent's turn, and a web chat connection that declared nothing have none), so a tool that needs it fails closed with a `ToolRefusal` when it is undefined.
630
- `client.names` lists the functions the client declared, and `client.call(name, args, { timeoutMs?, maxValueBytes? })` asks it to run one.
630
+ `client.names` lists the functions the client declared, and `client.call(name, args, { timeoutMs?, maxValueBytes?, signal?, approval? })` asks it to run one. `approval: { prompt }` names the approval the call acts on, normally `{ prompt: turn.approvedPrompt }` for a tool whose hold the person approved: the transport hands it to the client beside the call (the web chat as `client_call.approval`, see its README), so a client can check that the person approved this very card before it acts. A call without `approval` carries no such field; the core does not judge it, the client does.
631
631
  The call never rejects for a transport problem: it resolves `{ ok: true, value, parts }`, with the value the client answered with and its binary `parts` (`{ mimeType, data }`, such as a screenshot), or `{ ok: false, error, message }` where `error` is `no_client`, `refused`, `timeout`, `cancelled`, `disconnected`, `too_large`, `busy`, or `failed`.
632
632
  One call is open on a client at a time: a second one made meanwhile resolves `busy`, so make calls one after another.
633
633
  The core gives every call the turn's `signal`, so stopping the turn cancels the call in flight (`cancelled`) and the tool need not handle it.
@@ -720,7 +720,9 @@ Transient `SessionContext.runTask` tasks return only text and cannot attach to t
720
720
  The model attaches a file it already has on disk with the core's `attach_file({ path, filename? })`. A session with a workspace (`SessionContext.workspace`, an agent's) registers it; the path must resolve, symlinks followed, inside the workspace or the scratch dir, a relative path resolves in the workspace, and `filename` defaults to the path's base name. It queues the file with `attachReplyFile`, so `REPLY_FILE_LIMITS` apply and a surface without reply files refuses it as a tool error; its result tells the model the file will appear with its reply. It holds nothing, since it only posts in the channel the turn answers in, and its tier defaults to the owner, like any tool the core's table does not name.
721
721
 
722
722
  With an `images` provider, agent and owner sessions also offer the core's `generate_image({ prompt, reference_paths? })`, which draws a new picture from a description (`prompt`, at most 4,000 characters). `reference_paths` lists up to 4 local images, checked like `attach_file` paths (inside the workspace or the scratch dir, symlinks followed, regular files only), each a PNG, JPEG, WebP or GIF by content, at most 20 MiB each and 40 MiB together; a refused reference is a tool error before the provider is called. It passes them to the provider as `ReferenceImage`s with the turn's signal, saves the picture under `<scratch dir or workspace>/drawings/generated-<ISO time>.<ext>`, and attaches it with `attachReplyFile`; when it cannot be attached the file is kept and the result says so. The result names the saved path, never the bytes, so the model can send it on or edit it. A provider error, such as a refusal, is a tool error carrying the provider's message. It holds nothing, and its tier defaults to the owner; `toolTiers` lowers it. The agents' prompt mentions it only when it is offered.
723
- The Discord tools that upload, `discord_send_message` and `discord_edit_message`, take each file as exactly one of `path` (a local file, read when the call runs, with the same roots; `filename` defaults to its base name) or `dataBase64` with a `filename`, at most 8 MiB in all. A session without a workspace takes only `dataBase64`, and so does remote MCP, whose schemas stay inline only. A path outside the roots, a missing file, or a directory is a tool error naming the path. A held call's card (`approvalCard(call, workspace?, limits?)`) lists each file it sends by path with the file's size now, never its bytes, and shows an input within `limits.totalChars` (1,500) whole; a longer input has its string values cut, longest first and never below `limits.valueChars` (200), to their start plus `… [N chars]` (N in code points) until it fits, and if it still does not, the rest is dropped and the keys no longer shown are listed. `approvalDetails(call, workspace?)` gives the same call as data (`{ action, tool, input, files? }`, the whole input, files as `{ path, bytes? }`); the gate passes it to `Prompts.confirm` as `details`, for a surface that is not text.
723
+ The Discord tools that upload, `discord_send_message` and `discord_edit_message`, take each file as exactly one of `path` (a local file, read when the call runs, with the same roots; `filename` defaults to its base name) or `dataBase64` with a `filename`, at most 8 MiB in all. A session without a workspace takes only `dataBase64`, and so does remote MCP, whose schemas stay inline only. A path outside the roots, a missing file, or a directory is a tool error naming the path. A held call's card (`approvalCard(call, workspace?, limits?, parameters?)`) lists each file it sends by path with the file's size now, never its bytes, and shows an input within `limits.totalChars` (1,500) whole; a longer input has its string values cut, longest first and never below `limits.valueChars` (200), to their start plus `… [N chars]` (N in code points) until it fits, and if it still does not, the rest is dropped and the keys no longer shown are listed. `approvalDetails(call, workspace?)` gives the same call as data (`{ action, tool, input, files? }`, the whole input, files as `{ path, bytes? }`); the gate passes it to `Prompts.confirm` as `details`, for a surface that is not text.
724
+
725
+ Given `parameters`, the tool's JSON Schema, `approvalCard` shows the input as fields instead of JSON, and the gate does so for every tool the session lists (it reads the schema from the tool list of the Pi session): one line or block per argument, in the order the schema declares its properties (an argument the schema does not list follows them, its name in code format so it cannot pass for a declared label), labelled by the property's `title` or else its name, absent arguments left out; only an argument's own properties count, so one named `toString` or `constructor` is shown with its value. A short value sits on the line (`**label**: value`, with markdown, links and `@everyone`/`@here` in it escaped so nothing in a model-written value formats or mentions anyone); a string with a line break or over 100 characters, and a nested value as compact JSON, go in a fenced block that keeps their line breaks and in which nothing is markdown (`\r`, `\u2028`, `\u2029` and `\u0085` are line breaks too); booleans, numbers and enum values show as they are; an array of scalars is joined with commas, unless an item is empty, shows nothing, holds a comma and a space or a line break, in which case the array is shown as JSON; a string that is empty or shows nothing (white space, zero-width characters) is written out as a quoted, escaped text, such as `""`, so the person sees it is there. `limits` apply as they do to the JSON: a long string is cut, the longest first and never below `limits.valueChars`, marked `… [N chars]`, and when that is not enough the last fields are dropped and their labels listed. A tool with no schema, or one that lists no properties (and a card a plugin builds without `parameters`, such as the coding desk's), shows the JSON as before. The card is text, so every surface that shows `message` shows the fields; `approvalDetails` is unchanged.
724
726
  Calling the helper outside `context.turns.run` or an agent-team turn, or after that turn finishes, throws `ReplyFileError`; direct standalone runtime calls have no reply collector.
725
727
  Await work that produces files before returning from the tool.
726
728
  A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
@@ -826,6 +828,32 @@ hold: async ({ customer, body }, turn) => ({
826
828
 
827
829
  `text` is the description, with every behaviour above; `copy` goes to the gate's `ApprovalDetails.copy` for that call, so the Discord card gets the [copy button](#who-answers-a-prompt-promptscope) with all of its limits (the 20,000 character cap, the label, `discord.cardCopies`), and the web chat forwards it as `approval.copy`. It is part of the same answer, so a rejection, a throw or a timeout drops it with the description: the call is held under the generic description with no copy, and the exact input is still on the card. A `copy` whose `text` is not a non-empty string is left out and the call is still held. The copy is not kept with the held call (`HeldCall` has no copy), so a card the owner answers by a message after a restart has no button, and a host that shows no card, or that checks the hold with no turn, such as a precheck script's call, uses `text` and ignores `copy`. A hold that answers a plain string is unchanged. The copy is visible to anyone who sees the card, so it holds nothing they may not read.
828
830
 
831
+ #### Withdrawing a held call
832
+
833
+ A hold that no longer wants a call it held, because a newer request replaced it or its subject is gone, withdraws it by answering `{ text, supersede }` with an `AbortSignal` of its own and aborting the signal when the time comes (`HoldDescription.supersede`, beside `copy`; a tool's `hold` and a rule's `describeInTurn` both may):
834
+
835
+ ```ts
836
+ const replaced = new AbortController();
837
+ hold: (args) => ({ text: `send ${args.to} the quote`, supersede: replaced.signal }),
838
+ // later, when a newer quote for the same customer replaces it:
839
+ replaced.abort("replaced by a newer quote");
840
+ ```
841
+
842
+ Aborting does four things, whichever way the call was held, and the abort reason, when it is a text, is the footer the closed card shows (`withdrawalNote(signal, fallback)` gives the one-line text, cut to 200 characters; any other reason, and no reason, gives `fallback`):
843
+
844
+ - Its approval card closes: controls off and the footer instead of "answer whenever you are ready", **also after the turn stopped waiting** (the card then stays open for as long as the process runs, and now closes by itself). On Discord the message is edited; on the web chat the prompt closes with the outcome `withdrawn` and the reason as `note`. A card that arrives with the signal already aborted is not shown. A surface implements this through `ApprovalOptions.supersede`, which the contract checks (`describeSurfaceContract`: an approval its plugin withdraws resolves `cancelled` and closes, and one answered first stays answered).
845
+ - The held call is forgotten: it leaves the held actions (saved so, if it was held for a message), and an approval of the identical call that has not been spent yet, on the card late, or by a confirming message, no longer releases it. A repeated call is described by the hold anew.
846
+ - A turn still waiting on the card is told the call was withdrawn (progress reports `refused: "withdrawn"`), not that it was declined or stopped. It does not retry.
847
+ - A late answer to the closed card, or a press that was being checked when the signal aborted, answers nothing and starts no turn.
848
+
849
+ **One outcome.** Withdrawing and approving are the two ways a held call leaves "may run", and the first one wins. An approval that was answered before the abort stands: the call runs once, or has run, and the abort changes nothing, the card keeps its answered footer. An abort that came first wins whatever the card then reports, also on a surface that ignores `supersede`, since the gate decides, not the card. Aborting twice, after the call ran, or after the card was declined does nothing. A call approved late but not made yet is withdrawable until the call is made.
850
+
851
+ **Identical calls.** A held call is identified by its tool and its canonical input, as everywhere in the gate. Withdrawing a call therefore removes every identical call held for the conversation, including one that another hold (another tool's rule, or a second request from the same plugin) held with the same tool and input, and an approval that is waiting for it. A plugin that needs to withdraw one of two identical requests apart from the other gives the calls different inputs.
852
+
853
+ **Nothing leaks.** The gate listens to the signal only while the call can still run: from the hold until it is released, refused, or no longer held (the next turn that does not confirm it drops it); a gate that is dropped with its conversation stops listening. A signal that is never aborted keeps nothing alive after that.
854
+
855
+ **After a restart.** A signal does not survive one, so a withdrawal cannot reach a call held before it: the stored call stays held, a confirming message still releases it, and its card (open cards are in memory) is gone anyway. The hold is what decides then: it describes the repeated call again, and a hold that answers with an already aborted `supersede` refuses the call without opening a card, drops a confirmation of it, and tells the model it was withdrawn. That is how a plugin withdraws what a restart left held.
856
+
829
857
  A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise, and a `HoldDescription` as well as a string, and which the gate asks instead of `describe` in a turn.
830
858
  `describe` is still required and is what a host without a turn sees. `holdChain(rules).inTurn(tool, input, context, turn)` asks the rules as a turn does, in order: each rule's `describeInTurn`, or its `describe` for a rule without one.
831
859
 
@@ -869,6 +897,8 @@ defineTool({
869
897
 
870
898
  `run` learns who approved: `turn.approvedBy` is `{ principalId, speakerId, name, owner? }` of the person who pressed the card, or whose message confirmed the call, and the same for a card answered after the turn stopped waiting. It is undefined for a call that was not held, and for a call a surface released without saying who (the web chat does not, for a call that names no approvers); a tool that must know the approver fails closed when it is undefined. `name` is the name the person's identity goes by on the surface, which they may change: look the sender up by `principalId`. `owner` is true when they approved as an owner. A surface reports it through `ApprovalOptions.approvedBy` of `Prompts.confirm`, and `LateAnswer.approvedBy`; for a call that names approvers it must, because the runtime accepts an approval from a named principal or an `owner` only: in a shared conversation an approval that does not say who gave it leaves the call held.
871
899
 
900
+ `run` also learns which card approved: `turn.approvedPrompt` is the id of the approval card whose answer released the call, for a tool that must show a client which approval it acts on (it passes it as `approval: { prompt }` in the options of `turn.client.call`). It is set when the person approved the held call on its card, in the turn that asked, or, for a card answered after that turn stopped waiting, in the later turn whose call that approval releases. It is undefined for a call that was not held; for one a message confirmed (no card was involved, and a restart keeps no cards); for one a surface released without a card id; and in a host with no turn, such as a precheck script's call. Discord's id is the one in the custom ids of its card buttons (`roundtable:card:<id>:yes`), the web chat's is the `id` of the `prompt` frame, and a surface reports it through `ApprovalOptions.approvedPrompt` of `Prompts.confirm` and `LateAnswer.prompt`. It comes from the surface, never from the model or the message text; a tool that needs it fails closed when it is undefined.
901
+
872
902
  #### The shell rule
873
903
 
874
904
  `shellHoldRule` in `pi-roundtable/kit` judges the agents' `bash`, `write` and `edit` calls; the agent server links it, and a session without a workspace has no shell.
@@ -2563,7 +2593,7 @@ The web chat shows prompts to the conversation's person only: its owners are not
2563
2593
 
2564
2594
  `confirm(title, message, signal?, minTier?, wait?, details?, options?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
2565
2595
  The confirmation gate and a coding worker's cards pass both.
2566
- `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. A surface may ignore both for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2596
+ `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. `approvedPrompt(prompt)` is called, likewise before `confirm` resolves `approved`, with the id of the card that approved the call, for `ToolTurn.approvedPrompt`; a surface whose cards have no stable id leaves it uncalled. A surface may ignore all three for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2567
2597
  A surface that shows text, such as Discord, uses `message` and reads only `details.copy` (below); the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2568
2598
  A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2569
2599
 
@@ -2571,11 +2601,11 @@ A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2571
2601
  It is informational and never an answer: using it does not approve, decline or otherwise settle the prompt, and a surface that cannot show it ignores it, as the web chat's own client does (the frame carries it as `approval.copy`, left out when it would not fit beside the call's `input` in the frame's 256 KiB: the input is never cut for it).
2572
2602
  On Discord the card gets a third button (`label`, default "Copy", or its Traditional Chinese equivalent on a host in that language) that any member who can see the card may press, any number of times, also after the card has ended; it replies ephemerally with the text in a code block, or as a `copy.txt` file when the text does not fit a message (2,000 characters) or holds a code fence. The button's custom id holds the card's id, never the text: the text is kept under that id in `card-copies.json` in the host's `dataDir`, in plain text and readable by the host's user only, so the button works after a restart even though the card itself no longer answers; a card whose text is gone says so. `discord.cardCopies` (`{ keepDays?, keep? }`, default the newest 100 cards for 30 days) sets how long and how many; `false` keeps the texts in memory only, with the same limits, so they are gone at a restart. The file is written in the background, one write at a time, so a card is not held up by it. A text past `keepDays` leaves the file too, not only memory: when the host starts or a button finds one expired, and on an hourly sweep that does not keep the process alive. The attachments' `retention` and discarding a conversation do not remove the texts: they age out, or set `cardCopies` to `false` on a host that wants none on disk: the host then deletes an existing `card-copies.json` when it starts. A text over 20,000 characters gets no button, and a warning is logged.
2573
2603
  Because anyone who can see the card can copy the text, it holds nothing they may not read.
2574
- `confirm` resolves `approved`, `declined`, `expired` (a card was shown and went unanswered), `unavailable` (no card could be shown: no one may approve the call, the speaker is below its tier, or the card could not be posted), `cancelled` (the turn stopped), or `pending` (with a `wait`). Both `expired` and `unavailable` leave the call held for the owner's next message; the turn's progress reports them as `refused: "expired"` and `refused: "held"`.
2604
+ `confirm` resolves `approved`, `declined`, `expired` (a card was shown and went unanswered), `unavailable` (no card could be shown: no one may approve the call, the speaker is below its tier, or the card could not be posted), `cancelled` (the turn stopped, or the plugin that held the call withdrew it through `ApprovalOptions.supersede`), or `pending` (with a `wait`). Both `expired` and `unavailable` leave the call held for the owner's next message; the turn's progress reports them as `refused: "expired"` and `refused: "held"`.
2575
2605
 
2576
2606
  #### A prompt that outlives its turn: `PromptWait`
2577
2607
 
2578
- `confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy? }`, `approvedBy` naming the approver where the surface knows them, or `{ kind: "question", answer, by }`, `by` naming who answered) and returns the text of the turn that tells the conversation, or undefined for none.
2608
+ `confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy?, prompt? }`, `approvedBy` naming the approver where the surface knows them and `prompt` the id of the card that was answered, or `{ kind: "question", answer, by }`, `by` naming who answered) and returns the text of the turn that tells the conversation, or undefined for none.
2579
2609
  Given one, a surface may stop the turn's wait after a grace period: `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
2580
2610
  Without one, a prompt waits for its answer, or for its turn's stop; a surface that cannot start a turn ignores `wait` and waits too.
2581
2611
  A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
@@ -2739,7 +2769,7 @@ export class FakeSurface implements ChatSurface {
2739
2769
  TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
2740
2770
  const principals = shared ?? (theirs ? [scope.principalId] : []);
2741
2771
  if (principals.length === 0 && !owners) return "unavailable";
2742
- return this.#ask(channel, title, signal, {
2772
+ return this.#ask(channel, title, [signal, options?.supersede], {
2743
2773
  principals,
2744
2774
  owners,
2745
2775
  ...(shared ? { minTier } : {}),
@@ -2787,9 +2817,12 @@ export class FakeSurface implements ChatSurface {
2787
2817
  #ask(
2788
2818
  channel: ChannelKey,
2789
2819
  title: string,
2790
- signal: AbortSignal | undefined,
2820
+ // The turn's stop and the plugin's withdrawal of the call both close the prompt as cancelled.
2821
+ closers: (AbortSignal | undefined)[],
2791
2822
  audience: Omit<Answering, "settle">,
2792
2823
  ): Promise<Approval> {
2824
+ if (closers.some((closer) => closer?.aborted))
2825
+ return Promise.resolve("cancelled");
2793
2826
  const asked = {
2794
2827
  id: `q${this.asked.length + 1}`,
2795
2828
  channel,
@@ -2804,9 +2837,10 @@ export class FakeSurface implements ChatSurface {
2804
2837
  resolve(answer);
2805
2838
  };
2806
2839
  this.#answering.set(asked.id, { ...audience, settle });
2807
- signal?.addEventListener("abort", () => settle("cancelled"), {
2808
- once: true,
2809
- });
2840
+ for (const closer of closers)
2841
+ closer?.addEventListener("abort", () => settle("cancelled"), {
2842
+ once: true,
2843
+ });
2810
2844
  });
2811
2845
  }
2812
2846
  }
@@ -3249,7 +3283,7 @@ It returns:
3249
3283
  A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
3250
3284
  `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
3251
3285
  The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
3252
- It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3286
+ It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3253
3287
  A check for a part the surface does not offer passes.
3254
3288
  The contract has no check for `ApprovalDetails.copy`: a surface that shows it keeps it informational, so using it never resolves, approves or declines the prompt, whoever uses it.
3255
3289
  `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
@@ -3730,6 +3764,7 @@ Import from the entries listed below; source area files are internal.
3730
3764
  | `attachReplyFile` | `pi-roundtable` | value |
3731
3765
  | `promptScope` | `pi-roundtable` | value |
3732
3766
  | `withReplyFiles` | `pi-roundtable` | value |
3767
+ | `withdrawalNote` | `pi-roundtable` | value |
3733
3768
  | `prepareImageBytes` | `pi-roundtable/kit` | value |
3734
3769
  | `ImagePreparationError` | `pi-roundtable/kit` | value |
3735
3770
  | `squarePng` | `pi-roundtable/kit` | value |
@@ -124,7 +124,7 @@ export class FakeSurface implements ChatSurface {
124
124
  TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
125
125
  const principals = shared ?? (theirs ? [scope.principalId] : []);
126
126
  if (principals.length === 0 && !owners) return "unavailable";
127
- return this.#ask(channel, title, signal, {
127
+ return this.#ask(channel, title, [signal, options?.supersede], {
128
128
  principals,
129
129
  owners,
130
130
  ...(shared ? { minTier } : {}),
@@ -172,9 +172,12 @@ export class FakeSurface implements ChatSurface {
172
172
  #ask(
173
173
  channel: ChannelKey,
174
174
  title: string,
175
- signal: AbortSignal | undefined,
175
+ // The turn's stop and the plugin's withdrawal of the call both close the prompt as cancelled.
176
+ closers: (AbortSignal | undefined)[],
176
177
  audience: Omit<Answering, "settle">,
177
178
  ): Promise<Approval> {
179
+ if (closers.some((closer) => closer?.aborted))
180
+ return Promise.resolve("cancelled");
178
181
  const asked = {
179
182
  id: `q${this.asked.length + 1}`,
180
183
  channel,
@@ -189,9 +192,10 @@ export class FakeSurface implements ChatSurface {
189
192
  resolve(answer);
190
193
  };
191
194
  this.#answering.set(asked.id, { ...audience, settle });
192
- signal?.addEventListener("abort", () => settle("cancelled"), {
193
- once: true,
194
- });
195
+ for (const closer of closers)
196
+ closer?.addEventListener("abort", () => settle("cancelled"), {
197
+ once: true,
198
+ });
195
199
  });
196
200
  }
197
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
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",
@@ -58,15 +58,15 @@
58
58
  "check:packages": "bun run --workspaces typecheck && bun run --workspaces lint && bun run --workspaces --sequential test"
59
59
  },
60
60
  "dependencies": {
61
- "@babel/parser": "7.29.9",
62
- "@babel/types": "7.29.8",
61
+ "@babel/parser": "8.0.7",
62
+ "@babel/types": "8.0.6",
63
63
  "@earendil-works/pi-ai": ">=0.99.2 <2",
64
64
  "@earendil-works/pi-coding-agent": ">=0.99.2 <2",
65
65
  "canvas": "3.2.3",
66
66
  "discord.js": "14.27.0",
67
67
  "pi-jev-compaction": "1.0.0",
68
68
  "pi-mcp-adapter": "5.0.0",
69
- "pino": "10.3.1",
69
+ "pino": "10.4.0",
70
70
  "typebox": "1.3.34",
71
71
  "unpdf": "1.8.1"
72
72
  },
@@ -50,6 +50,17 @@ export interface ToolTurn {
50
50
  * that must know the approver fails closed when it is undefined.
51
51
  */
52
52
  approvedBy?: ApprovedBy;
53
+ /**
54
+ * The id of the approval card that released this call, for a tool that must show a client which
55
+ * approval it acts on (pass it as `ClientCallOptions.approval.prompt`). Set when the person
56
+ * approved the held call on its card (Discord's card id, the web chat's `prompt` id), in the turn
57
+ * that asked or, for a card answered after that turn stopped waiting, when a later turn makes
58
+ * the identical call. Undefined for a call that ran without a hold, one a message confirmed
59
+ * (including after a restart, which keeps no cards), one a surface released without a card id,
60
+ * and in a turn that has no gate, such as a precheck script's call; a tool that needs it fails
61
+ * closed. It is never from the model or the message text.
62
+ */
63
+ approvedPrompt?: string;
53
64
  /**
54
65
  * What the client that started the turn can do for this tool, such as run a function in the
55
66
  * browser the person chats from; undefined for a turn whose transport gives none, such as a
@@ -117,6 +128,10 @@ export interface ToolSpec<Schema extends TObject> {
117
128
  * answer as the description, so it shares its wait and its failure: a hold that rejects or
118
129
  * outlasts the timeout holds the call with the generic description and no copy. A host with no
119
130
  * card, or that checks the hold with no turn, uses `text` and ignores `copy`.
131
+ *
132
+ * It may add `supersede`, an `AbortSignal` the plugin aborts to withdraw the call it held (a
133
+ * newer request replaced it): the card closes, the held call is forgotten, and an approval after
134
+ * that runs nothing; see `HoldDescription`.
120
135
  */
121
136
  hold?: (
122
137
  args: Static<Schema>,
@@ -9,7 +9,11 @@ import {
9
9
  TextInputStyle,
10
10
  } from "discord.js";
11
11
  import { messages } from "../i18n/index.ts";
12
- import type { OwnerAnswer, OwnerQuestion } from "../interactions/prompts.ts";
12
+ import {
13
+ type OwnerAnswer,
14
+ type OwnerQuestion,
15
+ withdrawalNote,
16
+ } from "../interactions/prompts.ts";
13
17
  import { type ownerPanel, plain } from "./owner-panel.ts";
14
18
 
15
19
  /** The prefix of every card control's custom id. */
@@ -96,3 +100,42 @@ export function answeredLine(answer: OwnerAnswer): string {
96
100
  ];
97
101
  return messages().cardAnswered(parts);
98
102
  }
103
+
104
+ /** An approval's controls: run, cancel, and, when the card offers a copy, a copy button that stays usable once it ended. */
105
+ export function approvalRows(
106
+ id: string,
107
+ disabled: boolean,
108
+ copy: { label: string } | undefined,
109
+ ): Rows {
110
+ return [
111
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
112
+ new ButtonBuilder()
113
+ .setCustomId(`${CARD_PREFIX}${id}:yes`)
114
+ .setLabel(messages().cardRunLabel)
115
+ .setEmoji("✅")
116
+ .setStyle(ButtonStyle.Success)
117
+ .setDisabled(disabled),
118
+ new ButtonBuilder()
119
+ .setCustomId(`${CARD_PREFIX}${id}:no`)
120
+ .setLabel(messages().cardCancelLabel)
121
+ .setEmoji("❌")
122
+ .setStyle(ButtonStyle.Secondary)
123
+ .setDisabled(disabled),
124
+ // Copying answers nothing, so it stays usable once the card has ended.
125
+ ...(copy
126
+ ? [
127
+ new ButtonBuilder()
128
+ .setCustomId(`${CARD_PREFIX}${id}:copy`)
129
+ .setLabel(copy.label)
130
+ .setEmoji("📋")
131
+ .setStyle(ButtonStyle.Secondary),
132
+ ]
133
+ : []),
134
+ ),
135
+ ];
136
+ }
137
+
138
+ /** The footer of a card its plugin withdrew: the reason it gave as plain text, or that the call was withdrawn. */
139
+ export function withdrawnFooter(signal: AbortSignal): string {
140
+ return plain(withdrawalNote(signal, messages().cardWithdrawn));
141
+ }
@@ -60,9 +60,10 @@ export interface OpenCard {
60
60
  rows(disabled: boolean): Rows;
61
61
  /**
62
62
  * Ends the card with its answer; the answering interaction shows the outcome. A card whose
63
- * turn stopped waiting starts a turn with the answer instead.
63
+ * turn stopped waiting starts a turn with the answer instead. False when the card had ended
64
+ * already, answered by another press or withdrawn by its plugin, and this answer changed nothing.
64
65
  */
65
- end(value: unknown, by: Answerer): void;
66
+ end(value: unknown, by: Answerer): boolean;
66
67
  question?: OwnerQuestion;
67
68
  /** Options chosen together with "Other…", kept while its form is open. */
68
69
  picked?: string[];
@@ -1,12 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import {
3
- ActionRowBuilder,
4
- ButtonBuilder,
5
- ButtonStyle,
6
- type Interaction,
7
- MessageFlags,
8
- TextDisplayBuilder,
9
- } from "discord.js";
2
+ import { type Interaction, MessageFlags, TextDisplayBuilder } from "discord.js";
10
3
  import { parseChannelKey } from "../contract/surface.ts";
11
4
  import type { ChannelKey } from "../domain/conversation.ts";
12
5
  import { messages } from "../i18n/index.ts";
@@ -30,11 +23,13 @@ import type { InteractionModule } from "./interaction-module.ts";
30
23
  import {
31
24
  answeredLine,
32
25
  answerModal,
26
+ approvalRows,
33
27
  askRows,
34
28
  CARD_PREFIX,
35
29
  OTHER,
36
30
  type Rows,
37
31
  TEXT_FIELD,
32
+ withdrawnFooter,
38
33
  } from "./owner-card-rows.ts";
39
34
  import type {
40
35
  Answerer,
@@ -158,52 +153,26 @@ export class OwnerCards implements InteractionModule {
158
153
  ? {
159
154
  wait: {
160
155
  pending: "pending",
161
- late: (value, by) =>
156
+ late: (value, by, prompt) =>
162
157
  wait.late({
163
158
  kind: "approval",
164
159
  approved: value === "approved",
165
160
  by: by.user.name,
166
161
  ...(by.approver ? { approvedBy: by.approver } : {}),
162
+ prompt,
167
163
  }),
168
164
  },
169
165
  }
170
166
  : {}),
171
- ...(options?.approvedBy
172
- ? {
173
- approved: (by: Answerer) => {
174
- if (by.approver) options.approvedBy?.(by.approver);
175
- },
176
- }
177
- : {}),
167
+ approved: (by: Answerer, prompt: string) => {
168
+ if (by.approver) options?.approvedBy?.(by.approver);
169
+ options?.approvedPrompt?.(prompt);
170
+ },
178
171
  title,
179
172
  sections: [message],
173
+ ...(options?.supersede ? { supersede: options.supersede } : {}),
180
174
  ...(copy ? { copy: copy.text } : {}),
181
- rows: (id, disabled) => [
182
- new ActionRowBuilder<ButtonBuilder>().addComponents(
183
- new ButtonBuilder()
184
- .setCustomId(`${CARD_PREFIX}${id}:yes`)
185
- .setLabel(messages().cardRunLabel)
186
- .setEmoji("✅")
187
- .setStyle(ButtonStyle.Success)
188
- .setDisabled(disabled),
189
- new ButtonBuilder()
190
- .setCustomId(`${CARD_PREFIX}${id}:no`)
191
- .setLabel(messages().cardCancelLabel)
192
- .setEmoji("❌")
193
- .setStyle(ButtonStyle.Secondary)
194
- .setDisabled(disabled),
195
- // Copying answers nothing, so it stays usable once the card has ended.
196
- ...(copy
197
- ? [
198
- new ButtonBuilder()
199
- .setCustomId(`${CARD_PREFIX}${id}:copy`)
200
- .setLabel(copy.label)
201
- .setEmoji("📋")
202
- .setStyle(ButtonStyle.Secondary),
203
- ]
204
- : []),
205
- ),
206
- ],
175
+ rows: (id, disabled) => approvalRows(id, disabled, copy),
207
176
  footer: messages().cardApprovalFooter,
208
177
  },
209
178
  );
@@ -257,19 +226,26 @@ export class OwnerCards implements InteractionModule {
257
226
  sections: string[];
258
227
  /** The text behind the card's copy button, kept under the card's id. */
259
228
  copy?: string;
229
+ /** Closes the card, also after its turn stopped waiting, when it aborts; see `ApprovalOptions.supersede`. */
230
+ supersede?: AbortSignal;
260
231
  question?: OwnerQuestion;
261
232
  rows(id: string, disabled: boolean): Rows;
262
233
  footer: string;
263
234
  wait?: {
264
235
  pending: T;
265
- late(value: unknown, by: Answerer): ReturnType<PromptWait["late"]>;
236
+ late(
237
+ value: unknown,
238
+ by: Answerer,
239
+ prompt: string,
240
+ ): ReturnType<PromptWait["late"]>;
266
241
  };
267
242
  /** Hears who approved the card, before it resolves approved in its turn. */
268
- approved?(by: Answerer): void;
243
+ approved?(by: Answerer, prompt: string): void;
269
244
  },
270
245
  ): Promise<T> {
271
246
  const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
272
- if (signal?.aborted) return Promise.resolve(cancelled);
247
+ if (signal?.aborted || card.supersede?.aborted)
248
+ return Promise.resolve(cancelled);
273
249
  const wait = resume ? card.wait : undefined;
274
250
  const id = randomUUID();
275
251
  if (card.copy !== undefined) this.#copies.remember(id, card.copy);
@@ -282,20 +258,23 @@ export class OwnerCards implements InteractionModule {
282
258
  let finish: string | undefined;
283
259
  /** The turn went on without the answer; the card is the conversation's now. */
284
260
  let late = false;
261
+ /** Shows the card as ended with `footer`, or once it is posted. */
262
+ const close = (footer: string) => {
263
+ finish = footer;
264
+ void message
265
+ ?.edit(view(footer))
266
+ .catch((error: unknown) =>
267
+ logger.warn({ channelId, err: error }, "card not closed"),
268
+ );
269
+ };
285
270
  const settle = (value: T, outcome: string | undefined) => {
286
271
  if (!this.#open.delete(id)) return;
287
272
  clearTimeout(timer);
288
273
  signal?.removeEventListener("abort", abort);
274
+ card.supersede?.removeEventListener("abort", withdraw);
289
275
  if (late) return;
290
276
  // A card ended by an answer is edited by the answering interaction.
291
- if (outcome !== undefined) {
292
- finish = outcome;
293
- void message
294
- ?.edit(view(outcome))
295
- .catch((error: unknown) =>
296
- logger.warn({ channelId, err: error }, "card not closed"),
297
- );
298
- }
277
+ if (outcome !== undefined) close(outcome);
299
278
  resolve(value);
300
279
  };
301
280
  const view = (footer: string, disabled = true) =>
@@ -310,6 +289,14 @@ export class OwnerCards implements InteractionModule {
310
289
  card.audience.mentions,
311
290
  );
312
291
  const abort = () => settle(cancelled, messages().cardStopped);
292
+ /** The plugin withdrew the call: the card closes, its turn waiting or not, unless an answer came first. */
293
+ const withdraw = () => {
294
+ if (!this.#open.delete(id) || !card.supersede) return;
295
+ clearTimeout(timer);
296
+ signal?.removeEventListener("abort", abort);
297
+ close(withdrawnFooter(card.supersede));
298
+ if (!late) resolve(cancelled);
299
+ };
313
300
  const timer = wait
314
301
  ? setTimeout(() => {
315
302
  if (!this.#open.has(id)) return;
@@ -319,19 +306,22 @@ export class OwnerCards implements InteractionModule {
319
306
  }, graceMs)
320
307
  : undefined;
321
308
  signal?.addEventListener("abort", abort, { once: true });
309
+ card.supersede?.addEventListener("abort", withdraw, { once: true });
322
310
  const open: OpenCard = {
323
311
  title: card.title,
324
312
  sections,
325
313
  rows: (disabled) => card.rows(id, disabled),
326
314
  audience: card.audience,
327
315
  end: (value, by) => {
316
+ // Withdrawn or answered already: this answer changes nothing.
317
+ if (!this.#open.has(id)) return false;
328
318
  const answeredLate = late;
329
- if (value === "approved" && !answeredLate) card.approved?.(by);
319
+ if (value === "approved" && !answeredLate) card.approved?.(by, id);
330
320
  settle(value as T, undefined);
331
- if (!answeredLate || !wait || !resume) return;
321
+ if (!answeredLate || !wait || !resume) return true;
332
322
  try {
333
- const text = wait.late(value, by);
334
- if (text === undefined) return;
323
+ const text = wait.late(value, by, id);
324
+ if (text === undefined) return true;
335
325
  resume({
336
326
  channelId,
337
327
  messageId: by.messageId ?? `card-${id}`,
@@ -343,6 +333,7 @@ export class OwnerCards implements InteractionModule {
343
333
  } catch (error) {
344
334
  logger.warn({ channelId, err: error }, "late card answer lost");
345
335
  }
336
+ return true;
346
337
  },
347
338
  ...(card.question ? { question: card.question } : {}),
348
339
  };
@@ -453,10 +444,18 @@ export class OwnerCards implements InteractionModule {
453
444
  return undefined;
454
445
  })
455
446
  : undefined;
456
- card.end(
447
+ const accepted = card.end(
457
448
  approved ? "approved" : "declined",
458
449
  approver ? { ...by, approver } : by,
459
450
  );
451
+ // The plugin withdrew the call, or another press answered, while this one was being checked.
452
+ if (!accepted) {
453
+ await interaction.reply({
454
+ content: messages().cardInactive,
455
+ flags: MessageFlags.Ephemeral,
456
+ });
457
+ return true;
458
+ }
460
459
  await interaction.update(
461
460
  closed(approved ? messages().cardApproved : messages().cardDeclined),
462
461
  );
@@ -39,6 +39,13 @@ export interface ClientCallOptions {
39
39
  * need not pass the turn's signal; the core adds it to every call.
40
40
  */
41
41
  signal?: AbortSignal;
42
+ /**
43
+ * The approval this call acts on, for a client that must check the person approved it: `prompt`
44
+ * is the id of the approval card (`ToolTurn.approvedPrompt`). The transport hands it to the
45
+ * client beside the call; a tool passes it only when it has one, so a call without it carries
46
+ * no approval. The client, not the core, decides whether it satisfies the function.
47
+ */
48
+ approval?: { prompt: string };
42
49
  }
43
50
 
44
51
  /**
@@ -10,9 +10,15 @@
10
10
  * the owner turned down from a call that failed: `declined` on its approval card, `expired` when
11
11
  * a card was shown and went unanswered (the call is held for the owner's next message),
12
12
  * `pending` while the card stays open, and `held` when no card could be shown (no one may approve
13
- * it, or it could not be posted; the call waits for the owner's next message). A turn that was stopped is none of these.
13
+ * it, or it could not be posted; the call waits for the owner's next message), and `withdrawn` when the
14
+ * plugin that held it withdrew it (`HoldDescription.supersede`). A turn that was stopped is none of these.
14
15
  */
15
- export type HoldRefusal = "declined" | "expired" | "pending" | "held";
16
+ export type HoldRefusal =
17
+ | "declined"
18
+ | "expired"
19
+ | "pending"
20
+ | "held"
21
+ | "withdrawn";
16
22
 
17
23
  export type TurnProgress =
18
24
  | { type: "text"; delta: string }