pi-roundtable 0.9.6 → 0.9.8

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,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.8] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - A tool's `hold` may return a promise and takes the turn as a second argument, the `ToolTurn` its `run` receives (the speaker, the channel, the agent, and a `signal`), so a plugin can describe a held call with data it fetches as the current speaker, such as an entity's display name looked up with the speaker's own credentials. The confirmation gate awaits it before it posts the card, so the card, `ApprovalDetails.action` (the web chat's `approval.action`) and the held call carry the fetched text. It waits `HOLD_DESCRIBE_TIMEOUT_MS` (10 seconds, exported from `pi-roundtable/kit`); a rejection, a throw or a timeout holds the call under a generic description (`run <tool>`) and never lets it run. `signal` aborts when the turn stops or the wait ends, and a turn stopped meanwhile opens no card and holds nothing. Calls made together are held and shown in the order they were made, each with its own description.
13
+ - `HoldRule.describeInTurn(tool, input, context, turn)`, the same for a hold rule of a plugin's `holdRules`: it may return a promise and the gate asks it instead of `describe` in a turn. `HoldCheck.inTurn` asks a chain's rules as a turn does. `describe` stays required and is what a host with no turn sees.
14
+
15
+ ### Changed
16
+
17
+ - A hold that throws, not only one that rejects, is now held under a generic description in a turn; it used to fail the tool call. A hold that declares only its arguments and answers at once behaves exactly as before, and is still called with the arguments alone where a host checks the hold with no turn, such as a precheck script's tool call. A hold that declares `turn` is not called there: it is described as `run <tool>` and counts as held.
18
+ - Hold lookups are awaited one tool call at a time, because the agent loop checks calls one after another: a message with N calls whose lookups hang waits up to N × `HOLD_DESCRIBE_TIMEOUT_MS`, and an approved call in the confirming turn still waits for its lookup. Keep lookups fast. This is documented in `docs/plugins.md`; behavior is unchanged.
19
+ - A hold that declares `turn` through a wrapper (so its arity is not visible), or returns a thenable without `catch`, is now held as `run <tool>` where there is no turn, instead of failing with an obscure `TypeError`.
20
+
21
+ ## [0.9.7] - 2026-10-10
22
+
23
+ ### Added
24
+
25
+ - `ApprovalDetails` and `ApprovalFile` (`{ action, tool, input, files? }`, files as `{ path, bytes? }`): a held call as data, with its whole input and the size of each file it sends by path. `Prompts.confirm` takes it as a new optional last argument, `details`, beside the card's text `message`; the confirmation gate passes it with every card, and a surface that shows text only ignores it (Discord's cards read as before). `approvalDetails` builds it (`approvalDetails(call, workspace?)`), exported from `pi-roundtable/kit` beside `approvalCard`.
26
+ - `Approval` has a new value, `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). `expired` now means a card was shown and went unanswered. The Discord and web chat surfaces answer `unavailable` for the first case; a surface that cannot tell the two may keep answering `expired` for both, and the surface contract accepts either.
27
+ - `TurnProgress`'s `tool_end` takes an optional `refused` (`HoldRefusal`: `declined`, `expired`, `pending` or `held`), set when a hold refused the call and absent for a success and for a real failure. A card that was shown and went unanswered is `expired`; one that could not be shown is `held`. It is set through `PromptSlot` (`refuse`, `refusalOf`, in `pi-roundtable/kit`); a turn stopped while its card was open reports none.
28
+ - `approvalCard(call, workspace?, limits?)` takes `CardLimits` (also exported from the kit), `{ valueChars?, totalChars? }`, to set what its card shows of the input.
29
+
30
+ ### Changed
31
+
32
+ - An approval card shows an input that fits `totalChars` (1,500 by default) whole, exactly as before. Only an input over it has its string values cut, the longest first and never below `valueChars` (200 by default), each marked `… [N chars]` with the original length in code points, until it fits; so the keys after one long value are no longer lost. If it still cannot fit, the rest is dropped and the card lists the top-level keys it no longer shows. A cut never splits a surrogate pair.
33
+
8
34
  ## [0.9.6] - 2026-10-10
9
35
 
10
36
  ### Fixed
package/docs/plugins.md CHANGED
@@ -60,7 +60,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
60
60
  `ImagePreparationError.reason` is `byte-limit`, `pixel-limit`, or `invalid-image`; hosts can localize refusals without parsing native decoder errors.
61
61
  Ordinary 48 MP images and thin 9000×1 images remain admitted.
62
62
  This helper is not a downloader or filesystem validator.
63
- - Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
63
+ - Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard`, `approvalDetails` and `canonicalJson` for the cards of held actions.
64
64
  `InterimPoster(posts, { logger, channel, primaryChars?, editMs? })` posts a run's [interim text](#interim-text-what-a-turn-writes-before-its-final-answer) to an `InterimPosts`, such as a dispatch thread's `interim`: feed it `messageEnd(message)` and `toolStart(name)` from the session's events, bind `() => poster.flush()` as the slot's `beforeCard`, and `await poster.flush()` before the final report.
65
65
  - Diagnostics: `scrubDiagnostic(text, max = 600)` masks credentials (URL userinfo, token shapes, secret-named assignments and JSON fields, `Authorization`/`Cookie`/`x-api-key` headers, JWTs, PEM blocks), turns control characters other than tab and newline into spaces, and cuts the result at `max` characters.
66
66
  It scans only the first `max * 4` characters (at least 4,096), in linear time, so pass it git, gh or provider error text before showing that text to a user.
@@ -648,7 +648,7 @@ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundl
648
648
  Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
649
649
 
650
650
  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.
651
- 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?)`) lists each file it sends by path with the file's size now, never its bytes.
651
+ 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.
652
652
  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.
653
653
  Await work that produces files before returning from the tool.
654
654
  A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
@@ -714,6 +714,38 @@ export const cleanup = definePlugin({
714
714
  ```
715
715
  <!-- /example -->
716
716
 
717
+ #### A hold that looks things up
718
+
719
+ A hold may need something it must fetch as the speaker before it can describe the call, such as the display name of the entity an id stands for, read with the speaker's own credentials.
720
+ A tool's `hold` may then return a promise and takes the turn as a second argument: the `ToolTurn` its `run` receives, with the `speaker`, the `channel` and the `agent` of the turn that makes the call.
721
+
722
+ ```ts
723
+ defineTool({
724
+ name: "ticket_close",
725
+ description: "Close a ticket.",
726
+ parameters: Type.Object({ id: Type.String() }),
727
+ minTier: "member",
728
+ hold: async ({ id }, turn) => {
729
+ const ticket = await tickets.get(id, turn.speaker, turn.signal);
730
+ return `Close ticket ${ticket.title}`;
731
+ },
732
+ run: ({ id }) => tickets.close(id),
733
+ });
734
+ ```
735
+
736
+ The gate awaits the description before it posts the card, so the card's bold line, the `action` of `ApprovalDetails` (the web chat's `approval.action`) and the held call all carry the fetched text.
737
+
738
+ - `turn.signal` aborts when the turn stops, or when the wait ends. A turn stopped while its description is awaited opens no card and holds nothing.
739
+ - The wait lasts `HOLD_DESCRIBE_TIMEOUT_MS` (10 seconds, exported from `pi-roundtable/kit`). A rejection, a throw or a timeout holds the call under a generic description (`run <tool>`, in the host's locale), never lets it run: the owner still sees the exact input on the card.
740
+ - Returning `undefined` after the lookup lets the call run, as for a hold that answers at once.
741
+ - Calls made together are held, and their cards opened, in the order they were made, each with its own description, whichever lookup returns first.
742
+ - The agent loop runs one tool call's checks after another's, so hold lookups are awaited one call at a time: a model message with N calls whose lookups all hang waits up to N × `HOLD_DESCRIBE_TIMEOUT_MS` before the last card. This holds in the confirming turn too, where an already approved call still waits for its lookup before it is released. Keep lookups fast and give them a short timeout of their own.
743
+ - A hold must change nothing: it may be called again for the same call, such as when the owner's confirming message makes the call again.
744
+ - A hold that declares only its arguments and answers at once is called as it always was. Where a host checks the hold with no turn, such as the `holds` of a test harness or a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s tool call, a hold that declares `turn` is not called; it is described as `run <tool>` and so counts as held.
745
+
746
+ A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise and which the gate asks instead of `describe` in a turn.
747
+ `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.
748
+
717
749
  A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
718
750
  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.
719
751
  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`.
@@ -2365,7 +2397,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
2365
2397
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
2366
2398
  | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`; without a scope, the owners | the action is held until a message approves it |
2367
2399
  | `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
2368
- | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok }`. The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2400
+ | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2369
2401
 
2370
2402
  Each message a surface delivers says who wrote it in `actor`, the `ActorFacts` the identity service resolves: `provider` and `subject`, such as `discord` and the user id, `name`, `roles` written `<surface>:role:<name>` (left out where the surface does not know them, such as a Discord DM, which is not the same as none), and `legacyId`, the speaker id 0.8 gave the person, so someone carried over keeps their principal.
2371
2403
  A surface that leaves `actor` out has it read from `authorId`, `authorName`, and `authorRoleIds` with its prefix as the provider, and the host warns once that this goes away in 1.0.
@@ -2388,13 +2420,21 @@ A surface's prompts keep these rules:
2388
2420
 
2389
2421
  - The speaker's principal answers their own question on any of their identities the surface knows, and approves a call when their tier is at least its `minTier` (the owner tier when absent) and, for an owner-tier call, the principal is an owner; a surface that can check their tier again when they answer does.
2390
2422
  - Where the scope escalates to the owners, every owner may answer too, and an approval above the speaker's tier is the owners' alone.
2391
- - Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `expired` at once, shown to no one: the call stays held.
2423
+ - Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `unavailable` at once, shown to no one: the call stays held. (`expired` is for a card that was shown and went unanswered; a surface that cannot tell the two may answer `expired` for both.)
2392
2424
  - Anyone else is refused, even at the same tier as the speaker.
2393
2425
  - Without a scope, as for a turn with no speaker, the prompts are the owners'.
2394
2426
 
2395
2427
  On Discord the owners are every owner with a Discord identity, and the primary owner always; a card in a thread mentions the identity that spoke when it is theirs and one of the principal's Discord identities, every Discord identity of the principal when the speaker is none of them, and the owners when it goes to them, so a single owner's cards read as they did in 0.8.
2396
2428
  A principal with no Discord identity, such as the reporter of a background report, answers nothing there, so their card goes to the owners; so does an owner-tier call of a turn whose tier defaulted to owner when its principal is not an owner.
2397
- The web chat shows prompts to the conversation's person only: its owners are not on it, so an approval above that person's tier expires at once whatever the scope says.
2429
+ The web chat shows prompts to the conversation's person only: its owners are not on it, so an approval above that person's tier comes back `unavailable` at once whatever the scope says.
2430
+
2431
+ #### An approval as data: `ApprovalDetails`
2432
+
2433
+ `confirm(title, message, signal?, minTier?, wait?, details?)` 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? }`.
2434
+ The confirmation gate and a coding worker's cards pass both.
2435
+ A surface that shows text, such as Discord, uses `message` and ignores `details`; the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2436
+ A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2437
+ `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"`.
2398
2438
 
2399
2439
  #### A prompt that outlives its turn: `PromptWait`
2400
2440
 
@@ -2518,7 +2558,7 @@ export class FakeSurface implements ChatSurface {
2518
2558
  /**
2519
2559
  * How a turn asks to approve a held action or answers a question. The scope names who may
2520
2560
  * answer: its speaker when their tier holds the call, and the owners unless the conversation
2521
- * is private, where a call above the speaker's tier goes to no one and expires at once.
2561
+ * is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
2522
2562
  */
2523
2563
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
2524
2564
  return {
@@ -2527,7 +2567,7 @@ export class FakeSurface implements ChatSurface {
2527
2567
  scope !== undefined &&
2528
2568
  TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
2529
2569
  const owners = scope?.escalate !== "none";
2530
- if (!theirs && !owners) return "expired";
2570
+ if (!theirs && !owners) return "unavailable";
2531
2571
  return this.#ask(channel, title, signal, {
2532
2572
  ...(theirs ? { principalId: scope.principalId } : {}),
2533
2573
  owners,
@@ -2885,7 +2925,7 @@ It returns:
2885
2925
  |---|---|
2886
2926
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2887
2927
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2888
- | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined` |
2928
+ | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined`; `holds.inTurn(tool, input, context, turn)` asks them as a turn does, and returns a promise when a hold looks things up |
2889
2929
  | `runTool(name, args, { speaker, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
2890
2930
  | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
2891
2931
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
@@ -3014,7 +3054,7 @@ It returns:
3014
3054
  A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
3015
3055
  `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.
3016
3056
  The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
3017
- 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; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `expired` at once and is not shown.
3057
+ 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; 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.
3018
3058
  A check for a part the surface does not offer passes.
3019
3059
  `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
3020
3060
 
@@ -3268,6 +3308,8 @@ Import from the entries listed below; source area files are internal.
3268
3308
  | `AgentTeam` | `pi-roundtable` | type |
3269
3309
  | `AgentTurnScope` | `pi-roundtable` | type |
3270
3310
  | `Approval` | `pi-roundtable` | type |
3311
+ | `ApprovalDetails` | `pi-roundtable` | type |
3312
+ | `ApprovalFile` | `pi-roundtable` | type |
3271
3313
  | `AskOption` | `pi-roundtable` | type |
3272
3314
  | `AttachmentFailure` | `pi-roundtable` | type |
3273
3315
  | `AttachmentPort` | `pi-roundtable` | type |
@@ -3318,6 +3360,7 @@ Import from the entries listed below; source area files are internal.
3318
3360
  | `HeldCall` | `pi-roundtable` | type |
3319
3361
  | `HoldCheck` | `pi-roundtable` | type |
3320
3362
  | `HoldContext` | `pi-roundtable` | type |
3363
+ | `HoldRefusal` | `pi-roundtable` | type |
3321
3364
  | `HoldRule` | `pi-roundtable` | type |
3322
3365
  | `HostEnv` | `pi-roundtable` | type |
3323
3366
  | `HostEnvironment` | `pi-roundtable` | type |
@@ -3534,6 +3577,7 @@ Import from the entries listed below; source area files are internal.
3534
3577
  | `AgentTurnRunner` | `pi-roundtable/kit` | type |
3535
3578
  | `AssistantLike` | `pi-roundtable/kit` | type |
3536
3579
  | `Backlog` | `pi-roundtable/kit` | type |
3580
+ | `CardLimits` | `pi-roundtable/kit` | type |
3537
3581
  | `CategoryLayout` | `pi-roundtable/kit` | type |
3538
3582
  | `ChannelMessage` | `pi-roundtable/kit` | type |
3539
3583
  | `ChannelQueue` | `pi-roundtable/kit` | type |
@@ -3581,6 +3625,7 @@ Import from the entries listed below; source area files are internal.
3581
3625
  | `YesNoQuestion` | `pi-roundtable/kit` | type |
3582
3626
  | `activeToolsExtension` | `pi-roundtable/kit` | value |
3583
3627
  | `approvalCard` | `pi-roundtable/kit` | value |
3628
+ | `approvalDetails` | `pi-roundtable/kit` | value |
3584
3629
  | `archiveSessions` | `pi-roundtable/kit` | value |
3585
3630
  | `attachmentsOf` | `pi-roundtable/kit` | value |
3586
3631
  | `callScheduleTool` | `pi-roundtable/kit` | value |
@@ -3593,6 +3638,7 @@ Import from the entries listed below; source area files are internal.
3593
3638
  | `formatModelRef` | `pi-roundtable/kit` | value |
3594
3639
  | `headline` | `pi-roundtable/kit` | value |
3595
3640
  | `holdChain` | `pi-roundtable/kit` | value |
3641
+ | `HOLD_DESCRIBE_TIMEOUT_MS` | `pi-roundtable/kit` | value |
3596
3642
  | `COMPACT_HEADROOM_TOKENS` | `pi-roundtable/kit` | value |
3597
3643
  | `CompactionTiers` | `pi-roundtable/kit` | value |
3598
3644
  | `compactionEngine` | `pi-roundtable/kit` | value |
@@ -80,7 +80,7 @@ export class FakeSurface implements ChatSurface {
80
80
  /**
81
81
  * How a turn asks to approve a held action or answers a question. The scope names who may
82
82
  * answer: its speaker when their tier holds the call, and the owners unless the conversation
83
- * is private, where a call above the speaker's tier goes to no one and expires at once.
83
+ * is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
84
84
  */
85
85
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
86
86
  return {
@@ -89,7 +89,7 @@ export class FakeSurface implements ChatSurface {
89
89
  scope !== undefined &&
90
90
  TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
91
91
  const owners = scope?.escalate !== "none";
92
- if (!theirs && !owners) return "expired";
92
+ if (!theirs && !owners) return "unavailable";
93
93
  return this.#ask(channel, title, signal, {
94
94
  ...(theirs ? { principalId: scope.principalId } : {}),
95
95
  owners,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
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",
@@ -1,17 +1,14 @@
1
1
  import type { Static, TObject } from "typebox";
2
- import {
3
- AttachmentLookupError,
4
- openAttachment,
5
- type ToolAttachment,
6
- } from "./attachments/tool-attachment.ts";
2
+ import type { ToolAttachment } from "./attachments/tool-attachment.ts";
7
3
  import type { ReplyFile } from "./domain/conversation.ts";
8
4
  import { PluginError } from "./errors.ts";
9
- import type { HoldRule } from "./holds.ts";
5
+ import { type HoldRule, isPromise } from "./holds.ts";
6
+ import { messages } from "./i18n/index.ts";
10
7
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
11
- import { attachReplyFile } from "./reply-files.ts";
12
8
  import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
13
9
  import { toolError, toolText } from "./shared/tool-result.ts";
14
10
  import { type Speaker, TIERS, type Tier } from "./speakers.ts";
11
+ import { toolTurn } from "./tool-turn.ts";
15
12
 
16
13
  /** Tool names are lowercase words joined by underscores, like every tool the core ships. */
17
14
  const TOOL_NAME = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
@@ -64,8 +61,22 @@ export interface ToolSpec<Schema extends TObject> {
64
61
  minTier: Tier;
65
62
  /** Whether agents carry the tool in their turns; default true. */
66
63
  agent?: boolean;
67
- /** A short description of what the call would do when it must wait for approval; undefined lets it run. */
68
- hold?: (args: Static<Schema>) => string | undefined;
64
+ /**
65
+ * A short description of what the call would do when it must wait for approval; undefined lets it
66
+ * run. It may answer later, after looking something up as the turn's speaker, such as a name to
67
+ * show instead of an id: `turn` is the turn `run` would get, and its `signal` aborts when the
68
+ * turn stops or the wait ends. The owner's approval card waits for it up to
69
+ * `HOLD_DESCRIBE_TIMEOUT_MS`; a rejection or a timeout holds the call under a generic
70
+ * description, so a hold that fails never lets the call run. It must change nothing.
71
+ *
72
+ * A host that checks the hold with no turn, such as a precheck script's call, never calls a hold
73
+ * that declares `turn`; it sees the generic description. A hold that declares only `args` and
74
+ * answers at once is called there as it always was.
75
+ */
76
+ hold?: (
77
+ args: Static<Schema>,
78
+ turn: ToolTurn,
79
+ ) => string | undefined | Promise<string | undefined>;
69
80
  run(args: Static<Schema>, turn: ToolTurn): Promise<string> | string;
70
81
  }
71
82
 
@@ -124,29 +135,10 @@ export function defineTool<Schema extends TObject>(
124
135
  execute: async (_toolCallId, params, signal) => {
125
136
  try {
126
137
  return toolText(
127
- await spec.run(params as Static<Schema>, {
128
- speaker: context.speaker(),
129
- channel: context.turnChannel,
130
- agent: context.agent,
131
- signal,
132
- ...(context.workspace
133
- ? { workspace: context.workspace }
134
- : {}),
135
- attachFile: attachReplyFile,
136
- attachment: async (file) => {
137
- if (context.attachmentDir === undefined)
138
- throw new ToolRefusal(
139
- "this session keeps no attachments",
140
- );
141
- try {
142
- return await openAttachment(context.attachmentDir, file);
143
- } catch (error) {
144
- if (error instanceof AttachmentLookupError)
145
- throw new ToolRefusal(error.message);
146
- throw error;
147
- }
148
- },
149
- }),
138
+ await spec.run(
139
+ params as Static<Schema>,
140
+ toolTurn(context, signal),
141
+ ),
150
142
  );
151
143
  } catch (error) {
152
144
  if (error instanceof ToolRefusal) return toolError(error.message);
@@ -170,11 +162,38 @@ export function defineTool<Schema extends TObject>(
170
162
  hold: {
171
163
  name: `tool:${name}`,
172
164
  describe: (tool, input) =>
173
- tool === name ? hold(input as Static<Schema>) : undefined,
165
+ tool === name ? holdWithoutTurn(name, hold, input) : undefined,
166
+ describeInTurn: (tool, input, _context, turn) =>
167
+ tool === name ? hold(input as Static<Schema>, turn) : undefined,
174
168
  },
175
169
  };
176
170
  }
177
171
 
172
+ /**
173
+ * A tool's hold as a host with no turn sees it: called with the arguments alone, unless it declares
174
+ * the turn, and described generically when it needs the turn or answers later.
175
+ */
176
+ function holdWithoutTurn<Schema extends TObject>(
177
+ name: string,
178
+ hold: NonNullable<ToolSpec<Schema>["hold"]>,
179
+ input: Record<string, unknown>,
180
+ ): string | undefined {
181
+ if (hold.length >= 2) return messages().holdGeneric(name);
182
+ try {
183
+ const answer = (
184
+ hold as (
185
+ args: Static<Schema>,
186
+ ) => string | undefined | Promise<string | undefined>
187
+ )(input as Static<Schema>);
188
+ if (!isPromise(answer)) return answer;
189
+ // Its answer is not waited for here; a rejection must not surface on its own.
190
+ Promise.resolve(answer).catch(() => undefined);
191
+ } catch {
192
+ // A hold that needs the turn without declaring it, or that fails: it still counts as held.
193
+ }
194
+ return messages().holdGeneric(name);
195
+ }
196
+
178
197
  /**
179
198
  * Defines a plugin. It returns the plugin it was given, typed, after checking its name, so a
180
199
  * mistake shows where the plugin is written.
@@ -167,42 +167,48 @@ export class OwnerCards implements InteractionModule {
167
167
  confirm: async (title, message, signal, minTier = "owner", wait) => {
168
168
  const audience = await this.#audiences.approval(scope, minTier);
169
169
  // A private conversation's call above its person's tier is no one's to approve.
170
- if (!audience) return "expired";
171
- return this.#post<Approval>(channelId, signal, "expired", "cancelled", {
172
- audience,
173
- ...(wait
174
- ? {
175
- wait: {
176
- pending: "pending",
177
- late: (value, by) =>
178
- wait.late({
179
- kind: "approval",
180
- approved: value === "approved",
181
- by,
182
- }),
183
- },
184
- }
185
- : {}),
186
- title,
187
- sections: [message],
188
- rows: (id, disabled) => [
189
- new ActionRowBuilder<ButtonBuilder>().addComponents(
190
- new ButtonBuilder()
191
- .setCustomId(`${CARD_PREFIX}${id}:yes`)
192
- .setLabel(messages().cardRunLabel)
193
- .setEmoji("✅")
194
- .setStyle(ButtonStyle.Success)
195
- .setDisabled(disabled),
196
- new ButtonBuilder()
197
- .setCustomId(`${CARD_PREFIX}${id}:no`)
198
- .setLabel(messages().cardCancelLabel)
199
- .setEmoji("❌")
200
- .setStyle(ButtonStyle.Secondary)
201
- .setDisabled(disabled),
202
- ),
203
- ],
204
- footer: messages().cardApprovalFooter,
205
- });
170
+ if (!audience) return "unavailable";
171
+ return this.#post<Approval>(
172
+ channelId,
173
+ signal,
174
+ "unavailable",
175
+ "cancelled",
176
+ {
177
+ audience,
178
+ ...(wait
179
+ ? {
180
+ wait: {
181
+ pending: "pending",
182
+ late: (value, by) =>
183
+ wait.late({
184
+ kind: "approval",
185
+ approved: value === "approved",
186
+ by,
187
+ }),
188
+ },
189
+ }
190
+ : {}),
191
+ title,
192
+ sections: [message],
193
+ rows: (id, disabled) => [
194
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
195
+ new ButtonBuilder()
196
+ .setCustomId(`${CARD_PREFIX}${id}:yes`)
197
+ .setLabel(messages().cardRunLabel)
198
+ .setEmoji("✅")
199
+ .setStyle(ButtonStyle.Success)
200
+ .setDisabled(disabled),
201
+ new ButtonBuilder()
202
+ .setCustomId(`${CARD_PREFIX}${id}:no`)
203
+ .setLabel(messages().cardCancelLabel)
204
+ .setEmoji("❌")
205
+ .setStyle(ButtonStyle.Secondary)
206
+ .setDisabled(disabled),
207
+ ),
208
+ ],
209
+ footer: messages().cardApprovalFooter,
210
+ },
211
+ );
206
212
  },
207
213
  ask: async (title, question, signal, wait) => {
208
214
  const audience = await this.#audiences.question(scope);
@@ -239,13 +245,13 @@ export class OwnerCards implements InteractionModule {
239
245
  }
240
246
 
241
247
  /**
242
- * Posts a card and resolves with its answer, `expired` when it could not be posted, or
248
+ * Posts a card and resolves with its answer, `unposted` when it could not be posted, or
243
249
  * `cancelled`; with `wait`, and a way to resume, `wait.pending` once the grace period passes.
244
250
  */
245
251
  #post<T>(
246
252
  channelId: string,
247
253
  signal: AbortSignal | undefined,
248
- expired: T,
254
+ unposted: T,
249
255
  cancelled: T,
250
256
  card: {
251
257
  audience: Audience;
@@ -350,7 +356,7 @@ export class OwnerCards implements InteractionModule {
350
356
  })
351
357
  .catch((error: unknown) => {
352
358
  logger.warn({ channelId, err: error }, "card not posted");
353
- settle(expired, undefined);
359
+ settle(unposted, undefined);
354
360
  });
355
361
  });
356
362
  }
@@ -4,7 +4,24 @@
4
4
  * arguments, never their full text, and ends with whether it succeeded. The final reply still
5
5
  * arrives through `sendReply`.
6
6
  */
7
+
8
+ /**
9
+ * Why a hold refused a tool call, as a tool's end reports it, so a surface need not tell a call
10
+ * the owner turned down from a call that failed: `declined` on its approval card, `expired` when
11
+ * a card was shown and went unanswered (the call is held for the owner's next message),
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.
14
+ */
15
+ export type HoldRefusal = "declined" | "expired" | "pending" | "held";
16
+
7
17
  export type TurnProgress =
8
18
  | { type: "text"; delta: string }
9
19
  | { type: "tool_start"; id: string; tool: string; preview?: string }
10
- | { type: "tool_end"; id: string; tool: string; ok: boolean };
20
+ | {
21
+ type: "tool_end";
22
+ id: string;
23
+ tool: string;
24
+ ok: boolean;
25
+ /** Set only on a failed end that a hold refused; absent for a success and for a real failure. */
26
+ refused?: HoldRefusal;
27
+ };
package/src/core/holds.ts CHANGED
@@ -1,6 +1,13 @@
1
+ import type { ToolTurn } from "./define.ts";
1
2
  import { PluginError } from "./errors.ts";
2
3
  import { type Tier, tierAtLeast } from "./speakers.ts";
3
4
 
5
+ /**
6
+ * How long the confirmation gate waits for a hold that looks things up before it describes the
7
+ * call generically and holds it anyway, in milliseconds.
8
+ */
9
+ export const HOLD_DESCRIBE_TIMEOUT_MS = 10_000;
10
+
4
11
  /** What a hold rule knows about the session making the call. */
5
12
  export interface HoldContext {
6
13
  /** The shared workspace of a session with a shell; calls reaching outside it may be held. */
@@ -27,6 +34,20 @@ export interface HoldRule {
27
34
  * `describe` with an empty input.
28
35
  */
29
36
  mayHold?(tool: string): boolean;
37
+ /**
38
+ * `describe` for a rule that must look something up as the turn's speaker before it can describe
39
+ * the call, which it may answer later. The confirmation gate asks it, instead of `describe`,
40
+ * when a turn makes the call, and waits for the answer up to `HOLD_DESCRIBE_TIMEOUT_MS`; a
41
+ * rejection or a timeout holds the call under a generic description. `turn.signal` aborts when the
42
+ * turn stops or the wait ends. `describe` stays what a host without a turn sees, such as a
43
+ * precheck script's call, and answers at once.
44
+ */
45
+ describeInTurn?(
46
+ tool: string,
47
+ input: Record<string, unknown>,
48
+ context: HoldContext,
49
+ turn: ToolTurn,
50
+ ): string | undefined | Promise<string | undefined>;
30
51
  /**
31
52
  * The lowest tier that may approve a call this rule holds, when it is higher than the tool's own:
32
53
  * for a call that stands for others, such as saving a script whose runs make held calls.
@@ -46,6 +67,17 @@ export type HoldCheck = ((
46
67
  ) => string | undefined) & {
47
68
  /** The name of a rule that may hold some call of `tool` whatever its input; see `HoldRule.mayHold`. */
48
69
  mayHold?(tool: string): string | undefined;
70
+ /**
71
+ * The check as a turn runs it: each rule's `describeInTurn`, or its `describe` for a rule
72
+ * without one, asked in order until one describes the call. It answers at once when every rule
73
+ * asked did, and otherwise as a promise that rejects when a rule's does.
74
+ */
75
+ inTurn?(
76
+ tool: string,
77
+ input: Record<string, unknown>,
78
+ context: HoldContext,
79
+ turn: ToolTurn,
80
+ ): string | undefined | Promise<string | undefined>;
49
81
  /** The highest tier any rule requires to approve the call; see `HoldRule.approvalTier`. */
50
82
  approvalTier?(
51
83
  tool: string,
@@ -81,6 +113,26 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
81
113
  }
82
114
  return undefined;
83
115
  };
116
+ check.inTurn = (tool, input, context, turn) => {
117
+ const ask = (
118
+ from: number,
119
+ ): string | undefined | Promise<string | undefined> => {
120
+ for (let at = from; at < rules.length; at++) {
121
+ const rule = rules[at];
122
+ if (!rule) continue;
123
+ const description = rule.describeInTurn
124
+ ? rule.describeInTurn(tool, input, context, turn)
125
+ : rule.describe(tool, input, context);
126
+ if (isPromise(description))
127
+ return description.then((found) =>
128
+ found !== undefined ? found : ask(at + 1),
129
+ );
130
+ if (description !== undefined) return description;
131
+ }
132
+ return undefined;
133
+ };
134
+ return ask(0);
135
+ };
84
136
  check.mayHold = (tool) => rules.find((rule) => rule.mayHold?.(tool))?.name;
85
137
  check.approvalTier = (tool, input, context) =>
86
138
  rules.reduce<Tier | undefined>(
@@ -90,3 +142,12 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
90
142
  );
91
143
  return check;
92
144
  }
145
+
146
+ /** Whether a hold's answer comes later. */
147
+ export function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
148
+ return (
149
+ typeof value === "object" &&
150
+ value !== null &&
151
+ typeof (value as { then?: unknown }).then === "function"
152
+ );
153
+ }
@@ -28,11 +28,14 @@ export function conversationEn(ctx: CatalogContext) {
28
28
  answerReturned: (text: string) => `↩️ Reply:\n${text}`,
29
29
  thinkingAuto: "auto",
30
30
  confirmTitle: (asker: string) => `${asker} wants to run this action`,
31
+ holdGeneric: (tool: string) => `run ${tool}`,
31
32
  askTitle: (asker: string) => `${asker} has a question for you`,
32
33
  cardFile: (path: string, bytes: number | undefined) =>
33
34
  bytes === undefined
34
35
  ? `File \`${path}\` (not found now)`
35
36
  : `File \`${path}\` (${formatBytes(bytes)})`,
37
+ cardHiddenKeys: (keys: readonly string[]) =>
38
+ `… not shown: ${keys.join(", ")}`,
36
39
  };
37
40
  }
38
41
 
@@ -57,10 +60,12 @@ export function conversationZhTW(
57
60
  answerReturned: (text: string) => `↩️ 回覆:\n${text}`,
58
61
  thinkingAuto: "自動",
59
62
  confirmTitle: (asker: string) => `${asker} 要執行這個動作`,
63
+ holdGeneric: (tool: string) => `執行 ${tool}`,
60
64
  askTitle: (asker: string) => `${asker} 想問你`,
61
65
  cardFile: (path: string, bytes: number | undefined) =>
62
66
  bytes === undefined
63
67
  ? `檔案 \`${path}\`(目前找不到)`
64
68
  : `檔案 \`${path}\`(${formatBytes(bytes)})`,
69
+ cardHiddenKeys: (keys: readonly string[]) => `… 未顯示:${keys.join("、")}`,
65
70
  };
66
71
  }