pi-roundtable 0.22.5 → 0.23.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 (129) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
package/docs/plugins.md CHANGED
@@ -28,7 +28,7 @@ export default {
28
28
  } satisfies RoundtableConfig;
29
29
  ```
30
30
 
31
- Everything a plugin author needs comes from four entries, and nothing else can be imported from the package:
31
+ Everything a plugin author needs comes from five entries and one stylesheet, and nothing else can be imported from the package:
32
32
 
33
33
  | Entry | What it exports |
34
34
  |---|---|
@@ -36,6 +36,8 @@ Everything a plugin author needs comes from four entries, and nothing else can b
36
36
  | `pi-roundtable/testing` | Fixtures for testing plugins, without Discord or a database unless the test explicitly opens one; `testHost` names a few discord.js types (`ComposedCommands`, `CommandGuard`, `InteractionModule`, `RootOption`) so a test can drive the composed slash commands |
37
37
  | `pi-roundtable/kit` | Helpers for channel claims, tools, presentation, worker processes, and naming existing core parts |
38
38
  | `pi-roundtable/discord` | The entry built on discord.js types: the `DISCORD` service (slash commands, the owner guard), owner-command and panel helpers, the agent panel, and the channel-operation tables |
39
+ | `pi-roundtable/characters` | The composed characters: the catalog of classes and parts, the checks on a look, `suggestCharacter`, and `composeSvg`, which runs in a browser as well as on the server; see [Characters](#characters-composed-pictures-for-agents) |
40
+ | `pi-roundtable/characters.css` | The stylesheet that moves them (a file, not a module) |
39
41
 
40
42
  ### Advanced building blocks
41
43
 
@@ -169,6 +171,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
169
171
  | `events` | Where the core reports turns and team changes to every plugin's handlers |
170
172
  | `providers` | Each provider slot, from the plugin that fills it or the core's default; `providers.filled` is the set of slots a plugin fills |
171
173
  | `apiKey(provider)` | The credential the host's model login holds for a provider such as `openai-codex`, the same login the agents use. It resolves to `undefined` when the host has none for that provider and never throws for that. The value is a secret: keep it out of logs and error messages. Read it when you use it rather than keeping it, since a login may refresh its token |
174
+ | `listener(id)` | The address the host's listener `id` serves on, as configured: `{ socketPath, mode? }` for a unix socket or `{ port, hostname? }` for TCP (a frozen copy, without the id), or `undefined` for an id the host has no listener for. A plugin that serves without authentication reads it to refuse an address that reaches beyond the machine, as the web console's `auth: { mode: "none" }` does |
172
175
  | `queue` | The one channel queue that every conversation and channel operation shares |
173
176
  | `services` | The services plugins provide to each other, read by key: `services.get(SCHEDULES)`; see [services](#services-what-plugins-provide-to-each-other) |
174
177
  | `attachments` | Files a plugin takes from a person outside a turn, such as an upload, kept for a conversation until a turn uses them: [`attachments`](#contextattachments-files-from-outside-a-turn) |
@@ -233,6 +236,7 @@ The built-in plugins provide these, from the main entry:
233
236
  | `IDENTITY` | `IdentityService` | `identity` | Who the host serves, read-only: `resolve(facts, { conversation }?)` gives the `Speaker` with its `principalId` behind a surface's `ActorFacts`, or undefined; `principal(id)`, `list()`, `identities(principalId)`, `roles(principalId)`, `tierOf(principalId)` (the lasting roles' tier), `speakerFor(principalId, tier?)` for a turn on someone's behalf, `owners()`, and `principalOf(identity)`, the principal an identity written `<provider>:<subject>` is linked to, such as one a plugin declares in `identities`. Principals are written only by the configuration and `roundtable principal`. `SYSTEM_PRINCIPAL` is the host's own, which no plugin speaks as |
234
237
  | `IDENTITY_LINKS` | `IdentityLinks` | `identity`, only with `access.selfLink` | Lets a plugin link a person's own account to their principal, which `IDENTITY` cannot: `link(principalId, identity)` and `unlink(identity)`, identities written `<provider>:<subject>`. See [Self-linking](#self-linking). Absent unless the configuration lists the providers; read it with `find` |
235
238
  | `CONVERSATIONS` | `ConversationRegistry` | `conversations` | The conversations run through `context.turns`: `register`, `get(key)`, `list({ principal }?)`, `setTitle(key, title)`, and `adopt(key, principalId)` only for migrating a conversation recorded before 0.9; each a `ConversationRecord` of `key`, `surface`, `kind`, `visibility` (`"private"` or `"shared"`), `principalId?`, `title?`, `createdAt`, `lastActiveAt` |
239
+ | `MODES` | `ConversationModes` | `modes` | Each conversation's modes, stored per key and kept across restarts: `peek(channel)` gives its `ConversationMode` (`yolo` and `longRunning`, both off by default; `changedBy?`, `changedAt?`) from memory, `set(channel, { yolo?, longRunning? }, speaker)` changes them and throws `PluginError` unless the speaker is an owner other than `SYSTEM_PRINCIPAL`, `onChange(listener)` hears each change, and `autoApproved(entry)` with `onAutoApproved(listener)` report a held call that yolo mode approved. See [Conversation modes](#conversation-modes-modes) |
236
240
  | `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
237
241
  | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
238
242
  | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
@@ -241,11 +245,12 @@ The built-in plugins provide these, from the main entry:
241
245
  | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
242
246
  | `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
243
247
  | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
248
+ | `THREADS` | `DispatchThreads` | `threads` | The threads of background dispatches (coding tasks, delegated tasks, messages between agents), on any surface that hosts them: `open(origin, title, intro?)` opens the thread under the channel `origin` through the `threads` of the surface that serves its prefix and resolves to a `DispatchThread`, or `undefined` when that surface hosts none or the channel cannot. See [Dispatch threads on any surface](#dispatch-threads-on-any-surface) |
244
249
  | `WORKER_SESSIONS` | `WorkerSessions` | `worker-sessions` | Fresh Pi sessions for a plugin's own workers, on the host's model login: `delegation` is the host's delegation `model` and `thinking`, and `run({ task, signal, prompt, extensions, toolNames, model?, thinking?, aborted? })` runs the task in a session that has only the tools `extensions` register and `toolNames` names (no built-in tool, skill, context file, memory or agent tool) and returns the worker's final answer; see [Workers of your own](#workers-of-your-own) |
245
250
 
246
251
  The data types the ports use (`Schedule`, `NewSchedule`, `Agent`, `AgentGroup`, `TeamStatus`, `Memory`, `SkillSet`, `DelegationJob`, and so on) are in the main entry too.
247
252
  Your plugins run after the built-ins, so they can read every key above.
248
- The Discord connection's key, `DISCORD`, is in the Discord entry, because its port names discord.js types: `DiscordServices` has `connection`, `commands`, `guard`, `threads`, `poster` (see [Posting through `DISCORD.poster`](#posting-through-discordposter)) and `channelContext`; commands are in [`commands.add`](#slash-commands-commandsadd). A plugin's own stand-in for `DISCORD` (such as one written with `as unknown as DiscordServices`) needs a `poster` too before code that posts runs under it; `fakeDiscord()` and `testHost` give one.
253
+ The Discord connection's key, `DISCORD`, is in the Discord entry, because its port names discord.js types: `DiscordServices` has `connection`, `commands`, `guard`, `threads` (the same instance as `THREADS`), `poster` (see [Posting through `DISCORD.poster`](#posting-through-discordposter)) and `channelContext`; commands are in [`commands.add`](#slash-commands-commandsadd). A plugin's own stand-in for `DISCORD` (such as one written with `as unknown as DiscordServices`) needs a `poster` too before code that posts runs under it; `fakeDiscord()` and `testHost` give one.
249
254
 
250
255
  To share your own service, export its key as a constant and its port as an interface.
251
256
  Plugins registered after yours can read it during setup.
@@ -422,6 +427,29 @@ The default `"everyone"` is the 0.8 and 0.9 behavior.
422
427
 
423
428
  `discord: { agentSpeakers: "owners" }` (or the same key in `discord({ ... })`) limits who the agents answer: in the agents' and groups' channels only an owner-tier speaker starts a turn, and a message from an admin or member gets no reply and no turn. The default `"everyone"` answers every speaker the access rules admit. An outside webhook's post in an agent's channel is a report turn either way. Scheduled turns and delegated-work reports started for a non-owner are skipped the same way. Under `"owners"` a member's message can still reach an owner's turn as channel context. Use it on a host whose `access.admins` or `access.members` admit Discord: a non-owner turn runs in the agent's home session, shared with the owners, so it can read earlier owner conversation and shell output and leaves history that owner turns inherit. `roundtable doctor` warns about that combination.
424
429
 
430
+ #### Conversation modes: `MODES`
431
+
432
+ A conversation can run with less supervision when its owner switches a mode on. There are two:
433
+
434
+ | Mode | Effect |
435
+ |---|---|
436
+ | `longRunning` | A turn that reaches its time limit goes on by itself, up to `autonomy.longRunning.maxSeconds` of work, instead of asking on a card. See [long-running conversations](#long-running-conversations) |
437
+ | `yolo` | The server approves the held calls whose [hold classes](#hold-classes-and-yolo-mode) it allows itself, before a card is posted, and logs each one. What it allows is `autonomy.yolo.allow` and `autonomy.yolo.card` in the config |
438
+
439
+ Both are off for a conversation nobody has set. `MODES` stores them in the `conversation_modes` table, one row per conversation key, so they survive a restart, and works for any key; a surface decides who may toggle them. Read a mode where you act on it, with `peek`, which never waits on the database. Change it with `set`, which writes first and updates the memory afterwards:
440
+
441
+ ```ts
442
+ import { MODES } from "pi-roundtable";
443
+
444
+ const modes = context.services.get(MODES);
445
+ await modes.set(channel, { longRunning: true }, speaker); // speaker: an owner's Speaker
446
+ modes.peek(channel).longRunning; // true
447
+ ```
448
+
449
+ `set` throws `PluginError` for anyone but an owner, and for the host's own principal (`SYSTEM_PRINCIPAL`), so a webhook or a report turn cannot switch a conversation to run unattended. A change names only the modes it sets. It logs `conversation modes changed` with the channel, both modes and who changed them. `onChange(listener)` hears every change until the function it returns is called; a listener that throws is logged and the rest still hear it.
450
+
451
+ When a held call is approved by yolo mode, `ApprovedBy.auto` is `"yolo"` (a person's answer never sets it; `ToolTurn.approvedBy` shows it to the tool), and the approver reports it with `autoApproved(entry)`, which logs `held call approved by yolo mode` and tells the `onAutoApproved` listeners, so a surface can show it. What yolo approves, and what it never does, is under [hold classes and yolo mode](#hold-classes-and-yolo-mode).
452
+
425
453
  ## Principals and access
426
454
 
427
455
  A principal is the person the host serves; an identity is an account or credential linked to that person.
@@ -606,6 +634,8 @@ A key the contract does not have stops the start and names the closest one.
606
634
  A surface that takes a file as it arrives, such as Discord, hands the core a link, and the core downloads it into the conversation's attachment directory.
607
635
  The downloads of one message and of the message it replies to share a 60 second deadline, and a file that sends no byte for 10 seconds (the wait for its headers counts) is given up at once, so a stalled download costs 10 seconds, not the whole deadline. A file still to come when the deadline passes, a file that went quiet (`download failed: idle_timeout`), or one whose directory cannot be made, is reported as `download failed` and the turn goes on without it. `attachmentsOf(message, dir, logger, fetch?, { deadlineMs?, idleMs?, signal?, prepareImages? })` takes both limits; on the agent server they are `discord.attachmentDownload: { deadlineMs?, idleMs? }` (each 1,000 to 600,000 ms; defaults 60,000 and 10,000), for a host on a slow link or with large files. A failure reason carries a code only (`download failed: ENOTDIR`, `download failed: TimeoutError`, `download failed: error`), never the error's text, which could quote a path of the host.
608
636
  `prepareImages: false` (default `true`) downloads the files only: `images` is empty and an image that cannot be decoded is not reported, for a host that hands the files to the attachment store, which prepares the images itself, so they are not decoded twice and a failure is not reported twice.
637
+
638
+ A surface may take files larger than the 25 MiB every surface has by default. It declares `maxAttachmentBytes` (a positive integer of bytes) on its `ChatSurface`, and `attachmentsOf` takes the same number as `maxBytes`; the limit holds a ref's reported `size`, a file, a stream and a download alike, and a file over it is reported as `larger than <n> MB (<bytes> bytes)` (`larger than 25 MB` by default, which is what Discord keeps). A surface that stored the file itself hands it over without reading it into memory: `readAttachment(channel, ref)` (and `read` for `attachmentsOf`) may answer an `AttachmentContent`, which is the bytes, `{ path }` (an `AttachmentFile`: the absolute path of a regular file the surface owns, not a symlink; copied into the conversation's attachment directory and left where it is) or a `ReadableStream<Uint8Array>` (stored as it comes). Neither is held whole in memory, and the count is kept while they are copied, so a file that grows is cut off at the limit; a failed or cut-off copy leaves nothing behind, and a path that is relative, a symlink or not a regular file fails that file with `download failed: EINVAL`, a missing one with `ENOENT` (the error's code). The file is opened once and read from that handle. `context.attachments.save` stays capped at 25 MiB; only a surface's own attachments take its `maxAttachmentBytes`. A stored file over 25 MiB is not inlined: an image is not shown to the model (it is reported as an image that could not be decoded) and `read_attachment` refuses a text or PDF file that large, saying it is too large to read as text; the `## Attachments` block marks the file `too large for read_attachment; kept as a file for tools`, and a tool takes it through `turn.attachment(file)` (`stream()` reads it without buffering it). Smaller text, PDF and image files behave as before.
609
639
  A plugin whose clients upload a file themselves, over HTTP for instance, has the file before any turn exists.
610
640
  `context.attachments` keeps such a file for the conversation until a turn uses it:
611
641
 
@@ -666,7 +696,7 @@ It returns the text the model reads, or `{ text, images? }` (`ToolOutput`) to sh
666
696
  Only a channel's transport sets it, never the message text or the model, so a tool may write it on a record it creates, such as an issue's source line; a tool that needs it fails closed when it is undefined.
667
697
  A claim passes it to `context.turns.run` as `origin`, and the hold of the same call sees it too, since `hold` receives the same `ToolTurn`.
668
698
 
669
- `turn.attachment(file)` opens a file someone attached to the conversation, by the name the turn's `## Attachments` block lists, and returns `{ file, name, contentType, size, bytes() }`: a tool passes a user's file on without the model carrying its bytes.
699
+ `turn.attachment(file)` opens a file someone attached to the conversation, by the name the turn's `## Attachments` block lists, and returns `{ file, name, contentType, size, bytes(), stream() }` (`stream()` is the content as a `ReadableStream<Uint8Array>`, for a file too large to hold in memory): a tool passes a user's file on without the model carrying its bytes.
670
700
  Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads, and so does a file the host's [retention period](#retention-removing-used-files-after-a-period) removed, which says so.
671
701
  `turn.attachments?.()` lists those files, the newest first, as `{ file, name, contentType, size, addedAt }` (`ToolAttachmentInfo`): the names `attachment` opens and nothing else; a session that keeps no attachments lists none. The turns the core builds always have it; it is optional on the type for code that builds a turn by hand.
672
702
  Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
@@ -962,6 +992,36 @@ defineTool({
962
992
 
963
993
  `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.
964
994
 
995
+ #### Hold classes and yolo mode
996
+
997
+ Every held call carries its **hold classes**: ids of the form `<rule>.<class>` that name why it is held. They are what yolo mode (below) judges, and they are recorded with the call as `HeldCall.holdClasses`, so they survive a restart. A rule reports them with `holdClasses(tool, input, context)`, which lists every reason the call is held, not only the first. **Report only classes that are safe to approve with no one looking: a rule that reports classes has opted its calls in to yolo mode**, which approves them unless the config cards them. A rule that reports none leaves its calls **unclassed**: they carry the rule's bare name (`mail` for a rule called `mail`) and yolo cards them, unless `autonomy.yolo.allow` lists that name. A tool's own `hold` is a rule named `tool:<name>`, so it is unclassed, and `autonomy.yolo.allow: ["tool:repo_push"]` approves a `repo_push` tool's calls. Every reported id must start with the rule's name and a dot; one that does not, such as another rule's id, leaves the call unclassed, as does a `holdClasses` that throws. A rule that stands for other rules' calls, as the one that holds saving a precheck script does, declares `relay: true` and may list their ids instead; its calls also carry `<rule>.relay`, so `autonomy.yolo.card: ["precheck-scripts"]` cards them. A rule name has no dot. The same holds for `HoldDescription.classes`, and a hold check built without `holdChain` has no classes: yolo cards its calls. A rule that describes a call in a turn may name the classes in the description instead: `{ text, classes: ["repo.push"] }` (`HoldDescription.classes`). When several rules hold one call, its classes are all of theirs, so one unclassed or carded rule cards it. `holdChain(rules)` reports them as `classesOf(tool, input, context)` and, with the description, as `inTurnWithClasses(...)` (`ClassedHold`), which also asks the rules after the first that holds the call; a hold check built without `holdChain` has none.
998
+
999
+ **Yolo mode.** In a conversation whose `yolo` mode an owner switched on ([`MODES`](#conversation-modes-modes)), the server decides a held call before any card opens, and approves it without one only when **all** of these hold at that moment:
1000
+
1001
+ - the conversation's `yolo` mode is on (read when the call is held, so a card opened before the toggle stays open for a person to answer);
1002
+ - the turn's speaker is an owner, and not the host's own principal (`SYSTEM_PRINCIPAL`), which webhook and report turns speak as;
1003
+ - the call names no approvers and its card is not [the speaker's alone](#a-hold-that-makes-its-card-the-speakers-alone);
1004
+ - the call has hold classes (a call held before this existed, or by a check without classes, is unknown and always asks), and none of them is carded.
1005
+
1006
+ A class is carded when it is listed in `autonomy.yolo.card`, when its rule's name is, when it is in `YOLO_CARDED_SHELL_CLASSES` (`pi-roundtable/kit`) and `autonomy.yolo.allow` does not list it, or when it is a rule's bare name (the call is unclassed) and `autonomy.yolo.allow` does not list that name. A class in both lists stays carded. Everything else is approved. So a plugin rule that reports no classes, such as Merlin's `google`, `discord` and `mc` rules or a tool's own `tool:repo_push` hold, asks on a card in yolo conversations until the config allows it by name.
1007
+
1008
+ Yolo approves only where there is a turn: a call decided outside one (none of the host's own paths does) is held as before. It covers every owner-attributed turn in that conversation, including turns another agent starts with `message_agent` and turns a schedule starts for an owner's conversation; only turns the host's own principal speaks (webhooks, reports) always ask. Saving a precheck script through a schedule tool is classed by every held call the script makes, so yolo approves the save only if it would approve each of them (an inner call that is unclassed counts as its rule's bare name, and so does one whose arguments the script computes, besides its classes; a computed call of a classed tool counts as the bare name of its own rules, not of `precheck-scripts`); `autonomy.yolo.allow` listing `precheck-scripts` does not cover them, and `autonomy.yolo.card` listing it cards every such save. A redirect or `tee` target the shell cannot expand, such as `> $(printf /dev/sda)` or `> $DISK`, is `shell.disk`. The same holds for the targets of `>|`, `>& file`, `<>` and a `tee` behind a path or wrapper. A command substitution inside double quotes is judged like an unquoted one (inside single quotes it is text), so substitutions in a redirect or `tee` target card under yolo; this is a known cost of failing closed.
1009
+
1010
+ ```ts
1011
+ export default {
1012
+ autonomy: {
1013
+ yolo: {
1014
+ allow: ["shell.rm"], // yolo may also approve an rm outside the scratch roots
1015
+ card: ["shell.push", "tool:repo_push"], // never approve these, whatever the mode
1016
+ },
1017
+ },
1018
+ } satisfies RoundtableConfig;
1019
+ ```
1020
+
1021
+ An approved call runs in the same turn. It is reported as approved by the speaker with `ApprovedBy.auto: "yolo"`, logged as `held call approved by yolo mode` (channel, tool, classes, principal) and told to `MODES.onAutoApproved` listeners. A message that confirms a held call works as before. A turn with no card surface is decided the same way, so an owner's own turn there goes on instead of holding the call.
1022
+
1023
+ **Plain `git push` and writes outside the workspace are approved under yolo**, because they are the non-destructive shell classes (`shell.push`, `shell.write`). A plain push to `main` deploys wherever a push to `main` deploys. To keep it behind a card, put `shell.push` in `autonomy.yolo.card`; to keep every write outside the roots behind one, put `shell.write` there.
1024
+
965
1025
  #### The shell rule
966
1026
 
967
1027
  `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.
@@ -974,6 +1034,46 @@ The scratch dir is the config's `scratchDir`, by default `<os temp dir>/<discord
974
1034
  - `sudo`, `kill`, `dd`, `mkfs` and the other programs held whatever their arguments stay held, as do service, container, firewall and package changes, `git reset --hard`, and `gh` writes.
975
1035
  - `git push` is held, `force-pushes` for a force push and otherwise as a push to GitHub.
976
1036
 
1037
+ The rule reports the class of **every** held command in the line, including those inside `bash -c`, so a harmless write cannot hide a `sudo` behind it (`echo x > /etc/f; sudo ls` is `shell.write` and `shell.privilege`):
1038
+
1039
+ | Class | Held for |
1040
+ |---|---|
1041
+ | `shell.privilege` | `sudo`, `su`, `doas` |
1042
+ | `shell.disk` | `dd`, `mkfs*`, a redirect or `tee` to a device node such as `/dev/sda` (`/dev/null` runs) |
1043
+ | `shell.power` | `shutdown`, `reboot`, `poweroff`, `halt` |
1044
+ | `shell.kill` | `kill` and the name-matching killers |
1045
+ | `shell.rm` | an `rm` outside the scratch roots, or one the line cannot resolve |
1046
+ | `shell.write` | a redirect, `tee`, `write` or `edit` outside the scratch roots |
1047
+ | `shell.service` | `systemctl` and `service` changes |
1048
+ | `shell.docker` | stopping, removing or pruning containers, images and volumes; `docker compose down` |
1049
+ | `shell.firewall` | `ufw`, `iptables`, `ip6tables`, `nft` changes |
1050
+ | `shell.packages` | `apt`, `apt-get`, `snap`, `dpkg` installs and removals |
1051
+ | `shell.push` | a plain `git push` the push policy holds |
1052
+ | `shell.force-push` | a forced push, and a push that deletes, mirrors or prunes refs (`+` or `:` refspec, `--delete`, `--mirror`, `--prune`) |
1053
+ | `shell.reset-hard` | `git reset --hard` |
1054
+ | `shell.gh` | `gh` writes |
1055
+ | `shell.crontab` | `crontab -r` |
1056
+ | `shell.unreadable` | a line the rule cannot read to the end, so it cannot rule out a destructive command: longer than 200,000 characters, nested deeper than 48 levels, a here-document or `case` it cannot place, a script built at run time (`eval`, `bash -c` of a variable or a substitution), a shell that reads a script the line does not show as a word (`echo x \| bash`, a here-document or here-string for a shell, `source <(…)`, `. /dev/stdin`, a file the line wrote earlier and then runs, anything `xargs` fills in), `env -S`, a builtin's subscript that runs a substitution (`declare 'a[$(x)]=1'`), `PS4`, `PROMPT_COMMAND` and the other variables that run a command, or a rule that failed while judging |
1057
+
1058
+ The rule reads a line the way bash does, so text that only looks like a command stays unheld and text that runs is judged: quotes (`'…'`, `"…"`, `$'…'`, where a backslash escapes the next character), `$(…)` and backticks at any depth, `${…}`, comments (a `)` or a quote in one means nothing), here-document bodies up to their delimiter line (an unquoted body is still searched for substitutions, a quoted one is not), `case` patterns, arithmetic (`$((1<<2))` is a shift), line continuations, leading keywords (`then sudo ls`, `do rm -rf /x`), a descriptor before the command (`3>&1 sudo ls`), `eval`, `bash -c` (also `-ec`), and `find -exec`. Where it cannot tell, it holds the whole line as `shell.unreadable` instead of guessing; an error inside the rule also holds the call. So does a command whose name is a brace expansion, a pattern or a variable the line did not set to a literal (`{sudo,ls}`, `/usr/bin/su*`, `$cmd args`). The rule names programs it knows; a wrapper it does not list (`flock`, `chroot`, `ssh host`) runs its command unjudged. Known gaps: words in arithmetic (`$(( ))`, `(( ))`) are not commands, but a `$(` or backtick inside them is judged. A `bash -c` script is held whole only when the outer shell expands a substitution in it; a single-quoted script is judged like a line. `trap` scripts are judged. `alias NAME=…`, an assignment to `PROMPT_COMMAND`, `PS0`–`PS4`, `BASH_ENV`, `ENV` or `SHELLOPTS`, and `${x@P}` are held as `shell.unreadable`, because bash runs their value later. A line that evaluates arithmetic and also holds a literal `$(` or backtick (`x='a[$(cmd)]'; echo $((x))`) is held as `shell.unreadable`, so `echo '$(date)'; echo $((1 + 2))` is held too. Not followed: array subscripts and `printf -v` that evaluate a value, `source` of a file that defines aliases or traps, and a command run by a program the rule does not list.
1059
+
1060
+ The rule follows the variables a line assigns one after another, but bash may skip an assignment (`false && x=ls`, `(V=1)`, a branch not taken) or bind a name where the rule does not look (a loop, `read`, `declare`, `eval`, a function). So a variable the line itself binds is unknown as the name of a command or a script (`x=ls; $x -la` is held as `shell.unreadable`, besides the class of what it would run), and a variable it writes more than once is unknown everywhere, an `rm` operand included; `$HOME` and `$TMPDIR` keep their values.
1061
+
1062
+ The rule's one promise (if bash runs a held command, the line is held) is tested against real bash, under `src/core/modules/host-shell/`:
1063
+
1064
+ - `shell-policy.differential.test.ts` runs a hand-written corpus of lines in bash 3.2 and 5 on a `PATH` that holds only stubs. The stub `sudo` creates a marker file, the wrappers (`env`, `nice`, `timeout`, `xargs`, `find -exec`, `bash -c`, ...) run the program they are given, and nothing else exists, so nothing destructive runs. Every line that creates the marker must be held.
1065
+ - `shell-policy.fuzz.test.ts` does the same with generated lines: `shell-fuzz-grammar.ts` composes random lines from the grammar of bash (every quoting kind, escapes, `;` `&&` `||` `|` `&` and newlines including CRLF, subshells, groups, arithmetic, command and process substitution, backticks, here-documents and here-strings with every delimiter form, `case`, `for`, `while`, `until`, `select`, `if`, functions, `coproc`, aliases, redirections, comments, line continuations, `!` and `time`, wrappers, `trap`, `eval`, `bash -c`, brace and parameter expansions) and plants `sudo marker` at random places in many spellings. A line that creates the marker but is not held fails the test and is printed with its seed. It also reports how many lines of everyday commands and of harmless lines over the whole grammar the rule holds (false positives, which are reported and never fail the test).
1066
+
1067
+ ```sh
1068
+ bun test src/core/modules/host-shell/shell-policy.fuzz.test.ts # 2,000 lines from seed 1, about a minute
1069
+ ROUNDTABLE_SHELL_FUZZ=100000 bun test src/core/modules/host-shell/shell-policy.fuzz.test.ts # a long run
1070
+ ROUNDTABLE_SHELL_FUZZ=1 ROUNDTABLE_SHELL_FUZZ_BASE=<seed> bun test src/core/modules/host-shell/shell-policy.fuzz.test.ts # one line again
1071
+ ```
1072
+
1073
+ `ROUNDTABLE_SHELL_FUZZ` is the number of lines, `ROUNDTABLE_SHELL_FUZZ_BASE` the first seed (the others follow it) and `ROUNDTABLE_SHELL_FUZZ_JOBS` how many bash processes run at once (up to six by default; use `1` on a busy machine); a line is a function of its seed alone. Each line runs in a process group of its own, killed as a whole after 5 seconds or when the line ends, with a cap on the user's processes. A line the fuzz finds goes into the hand corpus, minimized, so the fix stays tested without the generator.
1074
+
1075
+ Yolo mode never approves these unless `autonomy.yolo.allow` lists them: `YOLO_CARDED_SHELL_CLASSES` is every class above except `shell.write` and `shell.push`; that includes `shell.unreadable`.
1076
+
977
1077
  `shellHoldRuleFor({ ownPushOwners?, heldPushRepos? })` (its options are the type `PushPolicy`) is the same rule with plain pushes to the owner's own repositories let through; `shellHoldRule` is `shellHoldRuleFor()`, which holds every push.
978
1078
  A push runs without a hold when all of these hold:
979
1079
 
@@ -1075,8 +1175,7 @@ export const library = definePlugin({
1075
1175
  displayName: "Librarian",
1076
1176
  prompt:
1077
1177
  "You keep the team's reading list. Answer briefly and cite what you were given.",
1078
- avatarPrompt:
1079
- "A calm librarian with round glasses and a stack of books",
1178
+ character: { class: "scholar", palette: "azure" },
1080
1179
  },
1081
1180
  ],
1082
1181
  }),
@@ -1446,13 +1545,13 @@ An unknown or misspelled slot stops startup with an error listing the valid name
1446
1545
  | Slot | The core's default | Your replacement |
1447
1546
  |---|---|---|
1448
1547
  | `judge` | Asks the configured model small questions: does this reply approve the held actions, how hard is this turn, which agents does this group message concern | An object with `askYesNo`, `askChoice`, and `askScore` |
1449
- | `images` | No drawing; each agent gets an avatar generated from its display name, and no session offers `generate_image` | `async (prompt, references, options?) => bytes` returning PNG, JPEG, WebP or GIF bytes; `options.signal` aborts with the turn that asked |
1548
+ | `images` | No drawing; no session offers `generate_image` | `async (prompt, references, options?) => bytes` returning PNG, JPEG, WebP or GIF bytes; `options.signal` aborts with the turn that asked |
1450
1549
  | `runtime` | None: the runtime plugin builds the Pi runtime itself | `(deps) => runtime`, an [`AgentRuntime`](#the-runtime-slot-replace-pi) that runs every conversation |
1451
1550
 
1452
- A filled `images` slot draws agents' avatars and also powers the core's [`generate_image`](#attach-files-to-the-agents-reply) tool.
1453
- When no `images` provider is configured, `agent_create` has no `avatar_prompt` parameter, `agent_avatar` is unavailable, and no session offers `generate_image`.
1454
- The owner's profile panel reports the missing provider and offers no redraw option.
1455
- Each agent gets a picture generated from its display name and the assistant's icon, so you can tell agents apart.
1551
+ A filled `images` slot powers the core's [`generate_image`](#attach-files-to-the-agents-reply) tool.
1552
+ `AgentTeam.redrawAvatar` and the `AvatarMode` type are deprecated, still work (with an image provider), and are removed in 1.0; use `AgentTeam.dress`.
1553
+ Agents' avatars are composed characters since 0.23 and do not use it; drawing them with the slot, `AvatarStudio.draw`, `edit`, `fallback` and `canDraw`, `config.avatar` (a warning is logged when it is set) and `AgentSeed.avatarPrompt` are deprecated, ignored, and removed in 1.0.
1554
+ When no `images` provider is configured, no session offers `generate_image`; `agent_create`, `agent_avatar` and the profile panel are the same either way.
1456
1555
  `bunx roundtable doctor` reports whether the slot is filled.
1457
1556
  The copy-in [`codex-images` provider template][codex-images-template] fills the `images` slot with `createCodexImages` from `pi-roundtable/kit`, which also exports the client it is built on, `drawCodexImage`.
1458
1557
 
@@ -1865,6 +1964,8 @@ The socket file's permission bits are `http.socketMode`, `0o660` by default: onl
1865
1964
  Widen it (`0o666`) only when the proxy runs as a user outside that group.
1866
1965
  More listeners than `public` come from `defineRoundtable`'s `listeners` override, each `{ id, socketPath, mode? }` or `{ id, port, hostname? }`, and a route attaches to one by its `id`.
1867
1966
 
1967
+ A listener takes request bodies up to 128 MiB (`DEFAULT_MAX_REQUEST_BODY_BYTES`, Bun's own default, set explicitly); a larger body is answered `413` before any handler runs. A route that takes bigger bodies, such as an upload endpoint, sets `maxRequestBodyBytes` on the `HttpRoute` (a positive integer: its upload limit plus the multipart overhead), and the listener opens with the largest value among its routes, a route that says nothing counting as the default. The host can fix the limit itself with `maxRequestBodyBytes` on a `ListenerConfig`, which then decides over the routes. The limit is per listener, not per route: a request to another route of the same listener may be as large. The same value holds a unix socket listener. A value that is not a positive integer is refused when the host starts, naming the route or listener.
1968
+
1868
1969
  A route that also declares `websocket` takes WebSocket upgrades on its path; every other request still reaches `handle`, and its `methods`, when listed, must include `GET`.
1869
1970
  An upgrade is a `GET` with `Upgrade: websocket`; one without `Sec-WebSocket-Key` or with a `Sec-WebSocket-Version` other than `13` gets `400`, and any other method goes to `handle` as usual.
1870
1971
  An upgrade request first passes the Origin check: `origins` lists the `scheme://host[:port]` values a browser may connect from, exactly as a browser sends them, such as `https://chat.example.com` or `chrome-extension://<id>` (a lowercase scheme and no path, trailing slash, query, fragment, or user; another form, and `"null"`, is refused at startup), and a request with another Origin, or none, gets `403` without reaching the route. `"any"` skips the check, for clients that are not browsers.
@@ -2000,7 +2101,7 @@ The plugin it writes lists every tool the package registers when it loads, gives
2000
2101
  A tool a package registers later, such as after a connection, is not found; add it to the list by hand.
2001
2102
 
2002
2103
  The built-in delegation worker also loads `pi-web-access` to search and read the web.
2003
- `pi-roundtable` lists it as a peer dependency (`>=0.35.0 <0.36.0`), so `bun add pi-roundtable` installs it for you.
2104
+ `pi-roundtable` lists it as a peer dependency (`>=0.35.0 <0.41.0`), so `bun add pi-roundtable` installs it for you.
2004
2105
  If your project depends on its own build, such as a fork, both the worker and `piPackages` use that copy without an `overrides` entry.
2005
2106
  A project that has none installed stops at the `modules` plugin's setup with a `PluginError` that names the command to run.
2006
2107
 
@@ -2124,6 +2225,7 @@ Use `parseChannelKey` to read a key, and check the surface in `owns`: `parseChan
2124
2225
  The built-in agent server claims `discord:` keys with `AGENT_SERVER_PRIORITY` (100), the highest claim priority.
2125
2226
  It owns the agents' channels and every other channel in its Discord guild.
2126
2227
  It leaves those other channels silent for the owner's notes, preventing other claims from answering there.
2228
+ Of any other surface it claims only the [agent rooms](#agent-rooms-on-any-surface) of a surface that declares `agentRooms: true`, `<surface>:agent.<name>` and `<surface>:group.<name>` for an agent or group the team has; every other key of that surface stays for your own claims.
2127
2229
  Your Discord claim won't receive messages from that guild at any priority below 100, including `priority: 10`.
2128
2230
  Claims on other surfaces, such as the example's `echo:` keys, don't compete with the agent server.
2129
2231
 
@@ -2387,7 +2489,7 @@ A plugin's claim opts in by calling `DISCORD.channelContext(message, options?)`
2387
2489
 
2388
2490
  It reads Discord, not the host's history, so it survives restarts:
2389
2491
 
2390
- - It fetches the `fetch` messages before the addressed one (default 50, at most 100) and keeps those after the assistant's latest post there: its bot user's, or one of its application's webhooks, such as an agent's voice. Any post of the assistant ends the window, including a notice that a conversation started over; the core posts no marker of its own, so a fresh-start reply must be a public post, not an ephemeral one, to cut the window.
2492
+ - It fetches the `fetch` messages before the addressed one (default 50, at most 100) and keeps those after the assistant's latest post there: its bot user's, or one of its application's webhooks, such as an agent's voice. Any post of the assistant ends the window, including the divider the core posts when a conversation starts over (see `discord.freshMarker`) and a notice that it did; a fresh-start reply must be a public post, not an ephemeral one, to cut the window.
2391
2493
  - It leaves out the assistant's own posts, the addressed message, empty messages, and every message it handed over before or that addressed it before in that channel, remembered in memory per channel, so a restart can repeat at most what came since the assistant's last post.
2392
2494
  - Other bots and integrations are kept and marked as bots; the host's owners (the primary owner and every other owner's Discord identities) are marked as owners.
2393
2495
  - Consecutive messages of one author at least `similarity` alike (default 0.8, by their longest common run of text) count as one, the later kept.
@@ -2623,6 +2725,7 @@ setup: ({ services }) => ({
2623
2725
  | `reaches(principalId)` | The person's own conversation here, as a channel key, or `undefined` when they have none here. It may reach the network and throw |
2624
2726
  | `knows(principalId)` | Optional: whether the person has a conversation here, from the host's own records such as their linked identities, without the network. A session asks it when it decides whether to offer `notify` or the schedule and delegation tools, so an outage then does not take them away; without it the host asks `reaches` |
2625
2727
  | `deliver(principalId, text)` | Optional: sends the notice itself. Without it the host posts the text in the channel `reaches` names, through the surface that serves it |
2728
+ | `mirror(principalId, text)` | Optional: echoes a notice another provider delivered, such as a web app pushing to a phone that Discord's direct message already reached. `notify` calls it on every provider but the one that delivered, only after a delivery; a failure is logged and never changes the result |
2626
2729
 
2627
2730
  The host reaches a person through the first provider, in contribution order, that reaches them.
2628
2731
  `context.directChannels.reach(principalId)` resolves that provider and channel, or `undefined`; `known(principalId)` resolves the first provider that knows the person without the network; `notify(principalId, text)` sends a notice there and resolves `false` when no provider reaches the person.
@@ -2644,7 +2747,7 @@ The surface delivers messages; the channel's owning claim decides what happens t
2644
2747
 
2645
2748
  A surface serves the channels whose key starts with its `surface` prefix: `surface = "fake"` serves `fake:<id>` keys, and only those reach its methods.
2646
2749
  The prefix is one non-empty word without a colon or a space, unique per host: two surfaces with one prefix stop the start, naming both plugins.
2647
- The agent server claims only `discord:` keys, so claims on your surface's channels don't compete with it.
2750
+ The agent server claims `discord:` keys, and, only on a surface that declares `agentRooms: true`, the `<surface>:agent.<name>` and `<surface>:group.<name>` rooms of agents and groups the team has; it claims no other key of your surface, so claims on your surface's other channels don't compete with it.
2648
2751
 
2649
2752
  | Method | What the surface does | Without it |
2650
2753
  |---|---|---|
@@ -2652,14 +2755,63 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
2652
2755
  | `start(deliver)` | Connects, and calls `deliver(message)` for every incoming message; the host passes its conversation router | (required) |
2653
2756
  | `sendReply(channel, reply)` | Posts an `OutboundReply`: a thinking line, a card, text chunks, then files | (required) |
2654
2757
  | `stop()` | Disconnects when the host stops | nothing to stop |
2758
+ | `agentRooms` | `true` makes the surface host [agent rooms](#agent-rooms-on-any-surface): a conversation with each agent and group of the team, as `<surface>:agent.<name>` and `<surface>:group.<name>` | no rooms; the agent team answers only in Discord |
2759
+ | `threads` | A `SurfaceThreads` that hosts [dispatch threads](#dispatch-threads-on-any-surface): a background dispatch started in one of the surface's channels opens its thread there, and the thread is the conversation `<surface>:<id>` | the dispatch posts in the channel as before |
2760
+ | `readAttachment(channel, ref)` | Returns the content of an attachment this surface made, such as an upload to a room, as `Promise<AttachmentContent>`: the bytes, `{ path }` of a file on the host, or a stream, the last two stored without holding them whole; the agent server's claim reads the refs of the surface's messages here and never fetches them over the network, and a rejection fails that file. A claim of your own passes the same function as `read` to `attachmentsOf`, or its refs are downloaded | the refs' URLs are downloaded, up to 25 MB each |
2761
+ | `maxAttachmentBytes` | The most bytes of one attachment a turn takes from this surface, for a ref, a file, a stream or a download alike; a positive integer, so a surface that keeps large uploads can raise it past 25 MiB. See [files from outside a turn](#contextattachments-files-from-outside-a-turn) | 25 MiB, as Discord's |
2655
2762
  | `formatting` | A short English text on how a reply renders here, such as "no Markdown tables"; the runtime appends it to the system prompt of every turn that replies on this surface, after the persona | nothing is appended |
2656
2763
  | `startTyping(channel)` | Shows a typing indicator until the returned function is called | none is shown |
2657
2764
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
2658
2765
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
2659
2766
  | `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`, and, where the surface declares `asks`, `questionnaire` ([several questions on one card](#several-questions-on-one-card-questionnaire-and-asksupport)); without a scope, the owners | the action is held until a message approves it |
2660
- | `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 |
2767
+ | `interim(channel, as?)` | 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. `as` is a `PostAuthor` (`{ name, agent? }`) when an agent of the team speaks, as `OutboundReply.author` is for the final reply | only the final reply is posted |
2661
2768
  | `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 |
2662
2769
 
2770
+ #### Agent rooms on any surface
2771
+
2772
+ A surface that declares `agentRooms: true` hosts a room for each agent and group of the agent team: `<surface>:agent.<name>` and `<surface>:group.<name>`, built with `agentRoomKey(surface, agent)` and `groupRoomKey(surface, group)` and read with `roomOf(key, roomSurfaces)`, which answers `{ kind, name }` for a key of that exact shape on one of the surfaces and `undefined` for any other, Discord's keys included.
2773
+ A room is a conversation of its own, apart from the agent's or group's Discord channel, and the same team answers in it: the same agents with the same prompt, model, notes and schedules.
2774
+ A room has its own session and held actions, so a card or an approving message in the web room does not touch the Discord channel's.
2775
+ A group's room keeps its own log, `<group>@<surface>`, with the seats `agentgroup:<surface>.group.<group>.<agent>`; the Discord channel's log and seats are untouched.
2776
+ A schedule an agent sets from a room belongs to the room.
2777
+ `message_agent` from a room talks to the other agent's room on the same surface; when the surface hosts [dispatch threads](#dispatch-threads-on-any-surface) it opens the exchange's thread in the sender's room, as it does in a Discord channel, and the request and the answer go there under each agent's name, and otherwise the answer returns to the sender's room.
2778
+ Starting a room over resets that room's session (or a group room's seats) alone.
2779
+ Archiving an agent or group clears the schedules and sessions of every key `AGENTS.team.roomsOf(name)` lists: its Discord channel, when it has one, then its room on each surface, and the room stops being owned: `AGENTS.team.owns(room)` is `undefined`, the agent server's claim no longer takes its messages, and a surface should refuse them (the web disables the composer) instead of delivering them to a room nobody answers in.
2780
+
2781
+ The team replies in a room through `context.surfaces.sendReply(room, reply)` with `OutboundReply.author` set to a `PostAuthor`, `{ name, agent? }`: the agent's display name and its stable name.
2782
+ Its interim posts go through `surfaces.interim(room, author)`.
2783
+ Neither goes through a Discord webhook.
2784
+ A surface shows the author as it likes; Discord's own surface ignores `author`.
2785
+ `context.surfaces.roomSurfaces()` lists the surfaces that declare `agentRooms`, once the plugins are linked.
2786
+
2787
+ Messages in a room are admitted by the agent server's claim like those in a Discord channel: an owner's message runs a turn, a group's round follows, and `speakers: "owners"` still applies.
2788
+ The claim reads the attachments of a room's message through the surface's `readAttachment`, so a ref the surface made is never fetched over the network; a surface without it has its refs downloaded from their URLs.
2789
+ `AGENTS.team.owns(key)` answers for a room too, only while its agent or group is active, and `AGENTS.team.coordinator()` names the coordinator, the agent in the entry channel, or on a host without Discord the one `coordinator` names.
2790
+
2791
+ #### Dispatch threads on any surface
2792
+
2793
+ A background dispatch (a coding task, a delegated task, a message between agents) gets a thread beside the channel that started it: one start line in the channel, then the dispatch's progress and report inside the thread.
2794
+ A surface hosts them with `threads`, a `SurfaceThreads`:
2795
+
2796
+ | Method | What the surface does |
2797
+ |---|---|
2798
+ | `open(parent, name, line)` | Posts the start line in `parent` and opens a thread from it. `line(mention?)` gives the start line's text: call it with no argument for the first post and with the thread's `mention` once the thread exists, so a surface that can edit its post shows the link. Resolves to `{ id, mention }`, or `undefined` when `parent` cannot host a thread, such as a direct message or a thread; rejects when the surface refuses, and no thread is made. The `id` is the surface's own and unique among its threads (the web's are `thread.<uuid>`); `mention` is how text names the thread (`<#id>` on Discord) |
2799
+ | `post(thread, text)` | Posts text in the thread |
2800
+ | `send(thread, text)` | Optional. Posts one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place; the dispatch's `DispatchThread` then carries `interim`, so a coding worker's progress edits in place. Without it the thread has none |
2801
+ | `close(thread)` | Archives the thread, and locks it where the surface allows |
2802
+
2803
+ The thread is the conversation `channelKey(surface, id)` (`app:thread.<uuid>` on a surface `app`): its owner cards, held actions and posts through `sendReply` use that key.
2804
+ `THREADS.open(origin, title, intro?)` picks the surface by the prefix of `origin`, and the thread lives on that surface; a channel whose surface has no `threads`, a channel that cannot host one, a failed creation, and a channel whose claim keeps reports in place (`postsInPlace`, such as an open channel) get none, and the dispatch posts in the channel as before.
2805
+ The `DispatchThread` it returns has `id`, `channel`, `mention`, `post(text)`, `interim?` and `close(report?)`, which posts the report and archives the thread once.
2806
+
2807
+ The host keeps a ledger of the threads still open, `dispatch-threads.json` in the data directory, keyed by each thread's channel key.
2808
+ Once every service has started after a restart, it closes the threads the last run left open through their surfaces, with a restart notice; a thread whose surface no longer hosts threads stays in the ledger with a warning and is retried at the next start.
2809
+ A ledger written before 0.23, keyed by Discord thread id, is read as `discord:` threads.
2810
+
2811
+ The Discord surface implements `SurfaceThreads` through its current connection, and `DISCORD.threads` is the same instance as `THREADS`, so a plugin that reads either sees no change.
2812
+ `ThreadHost`, the Discord-only port `DispatchThreads` took before 0.23, is deprecated until 1.0: pass `surfaces` instead of `host` to `new DispatchThreads(...)`.
2813
+ `fakeThreads()` from `pi-roundtable/testing` is dispatch threads over a fake Discord host.
2814
+
2663
2815
  A surface tells the model how its replies render with `formatting`: a short English text, kept under about 400 characters, which the runtime appends to the system prompt of every turn that replies on the surface, after the persona and everything appended before it, for the channel the turn replies in.
2664
2816
  One persona can answer on several surfaces, so the guidance comes from the surface and never from the persona.
2665
2817
  A turn whose channel has no surface, and a surface without `formatting`, add nothing.
@@ -2764,7 +2916,7 @@ Before any card, an `ask_user` question or an approval, the host posts the pendi
2764
2916
  The final reply is posted at the end as before, with its thinking line; an intermediate message is never the final one, so nothing is posted twice, and a steered run keeps its final text.
2765
2917
  A failed interim post or edit is logged and never fails the turn.
2766
2918
  Turns with nowhere to post, such as transient tasks and turns of a claim that passes its own `reply` to `context.turns.run`, post only their final reply.
2767
- A coding worker posts the same way into its dispatch thread, under the name its report uses, when the thread's host can edit its posts (`ThreadHost.send`, which the Discord thread host has; a `DispatchThread` then carries `interim`): its approval cards come after the text it wrote before them, and its report is posted once, at the end.
2919
+ A coding worker posts the same way into its dispatch thread, under the name its report uses, when the thread's surface can edit its posts (`SurfaceThreads.send`, which the Discord surface has; a `DispatchThread` then carries `interim`): its approval cards come after the text it wrote before them, and its report is posted once, at the end.
2768
2920
  A runtime that fills [the `runtime` slot](#the-runtime-slot-replace-pi) receives the place to post as `TurnRequest.interim` and may use it or not.
2769
2921
 
2770
2922
  The config's `interimText: "off"` posts only the final reply (default `"on"`), and `interimPrimaryChars` sets the length of primary text (default 400).
@@ -3020,7 +3172,7 @@ A test boots a host with the plugin, writes through the surface, and reads what
3020
3172
  ### `adapters`: the chat networks the host talks through
3021
3173
 
3022
3174
  `adapters` lists the chat networks the host talks through, each made by its adapter's factory.
3023
- In 0.8 the one adapter is Discord: `discord()` from `pi-roundtable/discord` takes the same settings as the top-level `discord`, and a host configured either way assembles the same plugins in the same order.
3175
+ Today the one adapter is Discord: `discord()` from `pi-roundtable/discord` takes the same settings as the top-level `discord`, and a host configured either way assembles the same plugins in the same order.
3024
3176
  The top-level `discord` stays the form `roundtable init` writes; configure Discord once, in one place or the other.
3025
3177
  A chat network that comes as a plugin, such as pi-roundtable-webchat's `webChat()`, stays in `plugins`.
3026
3178
 
@@ -3038,7 +3190,15 @@ export function withDiscordAdapter(
3038
3190
  dataDir: string,
3039
3191
  ): RoundtableConfig {
3040
3192
  return {
3041
- owner: { id: env("OWNER_ID"), name: env("OWNER_NAME") },
3193
+ access: {
3194
+ owners: [
3195
+ {
3196
+ name: env("OWNER_NAME"),
3197
+ principal: env("OWNER_ID"),
3198
+ identities: [`discord:${env("OWNER_ID")}`],
3199
+ },
3200
+ ],
3201
+ },
3042
3202
  adapters: [
3043
3203
  discord({
3044
3204
  token: env("DISCORD_TOKEN"),
@@ -3061,13 +3221,13 @@ The types are `AdapterConfig` and `DiscordConfig` from `pi-roundtable`, and `Dis
3061
3221
 
3062
3222
  ### A host without Discord
3063
3223
 
3064
- Leave `discord` out of `roundtable.config.ts` and the host runs without it: no Discord plugin, no agent server, no agents, and no skills.
3224
+ Leave `discord` out of `roundtable.config.ts` and the host runs without it: no Discord plugin, and no tool that needs Discord. With no `agents` there is no agent server, no agent and no skills either; with `agents`, [the team runs without Discord](#an-agent-team-without-discord).
3065
3225
  The runtime plugin still builds the runtime, so every claim that runs turns through `context.turns` works, over the surfaces your plugins bring.
3066
3226
  `http` is optional too; without it the host opens no listener, and a route then names a listener that is not configured.
3067
3227
 
3068
3228
  What needs Discord is refused or left out rather than failing later:
3069
3229
 
3070
- - `agents`, `skills` (anything but `false`), and `ops.agent` are configuration errors. `ops: { conversation: "<surface>:<id>" }` needs a chat surface that serves it and a claim that owns it and takes background turns, or the host does not start. Webchat declares `takesSystemReports: false` and takes no system error reports.
3230
+ - `ops.agent` is a configuration error, and so are `skills` (anything but `false`) without `agents`, and an agent seed's `channelId`. `ops: { conversation: "<surface>:<id>" }` needs a chat surface that serves it and a claim that owns it and takes background turns, or the host does not start. Webchat declares `takesSystemReports: false` and takes no system error reports.
3071
3231
  - `notify` is not registered unless a plugin contributes a [direct channel](#directchannels-reaching-a-person-on-their-own), since there is nowhere to send a notice.
3072
3232
  - `schedule_*` and `delegate_task` are registered only in a conversation whose claim takes background turns: their runs are turns of the [background target](#backgroundtargets-whose-turn-a-schedule-or-delegated-task-is) `PERSONAL_TARGET`, named `owner`, which the core contributes on every host. A conversation of a claim without `background` has neither; a no-surface conversation needs a registered private principal known to a direct channel, as [background targets](#backgroundtargets-whose-turn-a-schedule-or-delegated-task-is) describes.
3073
3233
  - `roundtable doctor` skips the Discord checks.
@@ -3076,6 +3236,18 @@ Pi's runtime still needs the `compact_session` tool in every session, from the P
3076
3236
  `roundtable init --adapter web` creates a host without Discord around the [web chat](#web-chat-pi-roundtable-webchat).
3077
3237
  A host that keeps Discord may write it as an adapter instead, as [`adapters`](#adapters-the-chat-networks-the-host-talks-through) describes.
3078
3238
 
3239
+ #### An agent team without Discord
3240
+
3241
+ `agents` and `skills` work without `discord`. The host then assembles the agent server and the skills addon whenever `agents` is not empty: the same team, with the same prompts, models, notes, schedules and skills, whose agents and groups have no Discord channel and are reached only through [rooms](#agent-rooms-on-any-surface) of a surface that declares `agentRooms: true`, such as `pi-roundtable-web`.
3242
+
3243
+ - `coordinator` names the agent that coordinates the team; it must be one of `agents` and defaults to the first. With `discord` the agent in `discord.entryChannel` is the coordinator, and `coordinator` is a `ConfigError`. The coordinator cannot be archived. `AGENTS.team.coordinator()` answers with either.
3244
+ - The team's id is `LOCAL_TEAM_ID`, `"local"`, where Discord's is the guild's id; the rows are stored under it as under the guild's, and `AGENTS.team.guildId` is it.
3245
+ - The start fails, naming the fix ("agents need a surface that hosts their rooms, such as pi-roundtable-web"), when no installed surface declares `agentRooms`: without Discord channels the agents could never be spoken to. The check runs when the plugins are linked, so a surface added by any plugin counts.
3246
+ - No channel is made, checked or arranged, and the dashboard (a Discord message) does not exist. `channel_arrange` and `channel_read` answer that the host has no Discord channels. `agent_create` and `group_create` make no channel, and a new agent starts its opening task in its room on the first surface that hosts rooms. Archiving an agent or group stops its rooms from answering.
3247
+ - An agent's pictures have no public address unless `http.publicUrl` is set: `AGENTS.avatars.url(hash)` is then the path `/avatars/<hash>.png`, which the surface serves itself through `AGENTS.avatars.serve(pathname)` under its own origin, as the web console does. With `http.publicUrl` they are served on the `public` listener as with Discord.
3248
+ - `AgentTeam.channelOf(name, near?)` answers with the agent's room on the surface of `near` (the asker's own conversation) when that surface hosts rooms, so `schedule_list` from a room reads the room's schedules; for an agent with no Discord channel it falls back to its room on the first surface that hosts rooms. The status the dashboard and the web read counts an agent's schedules, waiting turns and context use across its Discord channel and its rooms.
3249
+ - Breaking: `AgentGroup.channelId` is optional (`string | undefined`), and so is `GroupStatus.channelId`; a plugin that reads them handles a group without a Discord channel. The `agent_groups.channel_id` column drops its `NOT NULL` in a migration of the agent server (`agent-groups-channel-optional`), and `AgentServerOptions.guildId` and `entryChannelId` are optional, with `coordinator` added. `AvatarStudioOptions.publicUrl` is optional.
3250
+
3079
3251
  <!-- example: examples/headless.ts -->
3080
3252
  ```ts
3081
3253
  import { definePlugin, type RoundtableConfig } from "pi-roundtable";
@@ -3138,7 +3310,7 @@ The host listens on `127.0.0.1:3000`; serve it over HTTPS through a reverse prox
3138
3310
 
3139
3311
  A browser asks for a one-time ticket with `POST /chat/tickets` and `Authorization: Bearer <token>`, then opens `/chat/socket` offering the subprotocols `roundtable.webchat.v1` and `ticket.<ticket>`; another client sends the bearer token on the upgrade.
3140
3312
  The token never travels in a URL.
3141
- Each WebSocket message is one JSON object with a `type`: the client sends `send`, `stop`, `approval`, `answer`, and `auth`; the server answers with `ready`, `accepted`, `typing`, `stoppable`, `progress`, `reply`, `failed`, `prompt`, `prompt_closed`, `notice`, `reauth`, and `error`.
3313
+ Each WebSocket message is one JSON object with a `type`: the client sends `auth`, `send`, `stop`, `approval`, `answer`, `answers`, `prompt_features`, `client_hello`, and `client_result`; the server answers with `ready`, `accepted`, `typing`, `stoppable`, `progress`, `reply`, `failed`, `prompt`, `prompt_closed`, `notice`, `reauth`, `client_call`, `client_cancel`, and `error`. The [package README](https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md) lists each frame with its fields.
3142
3314
  `progress` carries the turn's [`TurnProgress`](#surfaces-a-chat-network-of-your-own) events, never the thinking and never a tool's full arguments; `reply` carries the answer in full markdown.
3143
3315
  The REST API lists a person's conversations, opens one, reads its messages, and lists and acknowledges private inbox notices from `notify`.
3144
3316
  Tickets, quotas, connections, and approvals follow `principalId`; refreshing a token to another principal closes the socket with 4403.
@@ -3168,6 +3340,32 @@ Leave out `web_search` and `fetch_content` unless people may make the host fetch
3168
3340
  [webchat-provider-settings]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#provider-settings
3169
3341
  [webchat-attachments]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#attachments
3170
3342
 
3343
+ ### The owner app: pi-roundtable-web
3344
+
3345
+ [pi-roundtable-web][web-package] is the owner's web app, and the reference surface for [agent rooms](#agent-rooms-on-any-surface).
3346
+ `webConsole(options)` adds a chat surface (prefix `app` unless `surface` says otherwise), a page, a WebSocket and a REST API under `/console` on the host's `public` listener, an installable manifest and service worker, and Web Push.
3347
+ It needs no Discord: with `agents` in the configuration and no `discord`, the team lives in the app's rooms (see [an agent team without Discord](#an-agent-team-without-discord)), and Home is the coordinator's room.
3348
+ Its README documents the app, every option, the protocol and the threat model; this section says which seams of the core it stands on, for a plugin that wants the same from another network.
3349
+
3350
+ | The app uses | For |
3351
+ |---|---|
3352
+ | `ChatSurface.agentRooms`, `agentRoomKey`, `groupRoomKey`, `OutboundReply.author`, `surfaces.interim(room, author)` | A room for each agent and group, with replies and live text shown under the agent's own name |
3353
+ | `ChatSurface.prompts` (its owner desk), `PromptScope`, `PromptWait`, `ApprovalOptions.purpose` | Approval, question, questionnaire and time-limit cards in the room, visible to every open page of every owner; a card the turn stopped waiting on stays open for a day and a late answer starts a turn |
3354
+ | `ChatSurface.threads` | Dispatch threads as read-only rooms nested under the room that started them |
3355
+ | `ChatSurface.readAttachment`, `maxAttachmentBytes`, `AttachmentContent` | Uploads of any kind up to `uploadMaxBytes`, handed to the agent as a stored file's path, never held whole in memory |
3356
+ | `MODES`, `autonomy`, `ApprovedBy.auto`, `MODES.onAutoApproved` | The yolo and long-running switches of a room, and the line in the room for each call yolo approved |
3357
+ | `DirectChannelProvider` with `mirror` | A notice to the owner lands in Home and is pushed to their devices; a notice another channel delivered is pushed too |
3358
+ | `context.listener(id)` | `auth: { mode: "none" }` refuses a listener that reaches beyond the machine |
3359
+ | `identities` with `token:web-console` | `none` and `password` speak for the primary owner (or `auth.principal`) without an `access.owners` entry |
3360
+
3361
+ The app has three ways to know who is asking, chosen with `auth`: `{ mode: "verifier" }` (a request verifier such as `cloudflareAccess(...)`), `{ mode: "password" }` (a login page and a signed cookie) and `{ mode: "none" }` (whoever reaches the listener).
3362
+ **Only a verifier resists an agent on the host.**
3363
+ The agents' shell runs as the service user and inherits its environment, so with `none` an agent can call the app's socket as the owner, approve its own held actions and turn yolo on, and with `password` it can read the password and the session key.
3364
+ Choose `none` or `password` for a host whose agents have no shell or run as another user, and keep a signing proxy for one whose agents run commands; the README's threat model has the details.
3365
+
3366
+ The app requires `AGENTS` for the panes that show the team, `MODES` for Rooms and `SCHEDULES` for Schedules, and the `memory` addon for Notes.
3367
+ A host that lists a pane the services cannot back does not start, and says which service is missing.
3368
+
3171
3369
  ### What plugins do not extend
3172
3370
 
3173
3371
  A plugin adds slash commands through the Discord plugin's registrar and never receives the composed commands (see [slash commands](#slash-commands-commandsadd)).
@@ -3184,6 +3382,62 @@ The following fields and parts from 0.1.0 were removed because only built-in plu
3184
3382
  | `RoundtablePlugin.agentServer()` and the `agentServer(outcome)` event | A service with `startInBackground`, and the `serviceStarted` event handler |
3185
3383
  | `RoundtablePlugin.stopTurn(channel)` | `stop(channel)` on the `ChannelClaim` that owns the channel |
3186
3384
 
3385
+ ## Characters: composed pictures for agents
3386
+
3387
+ `pi-roundtable/characters` draws an agent as a small character built from parts, so a look is data (a few ids and three colors) instead of a picture that has to be generated.
3388
+ The entry has no dependencies and imports nothing from Node, `canvas` or the core, so the same code runs in the server, in a browser bundle and in a plugin; a test holds it to that.
3389
+
3390
+ ```ts
3391
+ import { composeSvg, resolveCharacter } from "pi-roundtable/characters";
3392
+
3393
+ const look = resolveCharacter({ class: "knight", palette: "teal" });
3394
+ const svg = composeSvg(look, { look: "camelot-dark", frame: "shield", size: 256 });
3395
+ ```
3396
+
3397
+ ### The look: `CharacterSpec`
3398
+
3399
+ A `CharacterSpec` is a complete look: `v: 1`, a `class`, and the parts `outfit`, `hat`, `prop`, `hair`, `beard` (or `"none"`), `accessory` (or `"none"`), the `build` (`slim`, `regular` or `stout`), a `skin` tone `0`–`5`, a `hairColor`, and `colors` (`primary`, the agent's accent; `secondary`; `trim`), each a lowercase `#rrggbb`.
3400
+ `palette` names the preset the colors equal, and is absent when they are custom.
3401
+ A class is a preset of parts, not a body, so a new class is an outfit and gear: the 24 are `wizard`, `knight`, `scholar`, `smith`, `ranger`, `bard`, `alchemist`, `healer`, `steward`, `navigator`, `seer`, `scribe`, `engineer`, `druid`, `innkeeper`, `sentinel`, `envoy`, `rogue`, `monk`, `falconer`, `shipwright`, `jester`, `paladin` and `archivist`.
3402
+ `CATALOG` lists them with their `label`, the `jobs` they suit and the `keywords` that suggest them (English and zh-TW), and lists every outfit, hat, prop, hair, beard, accessory, the eight palettes (`azure`, `gules`, `vert`, `purpure`, `amber`, `sable`, `teal`, `rose`), the six skin tones and the hair swatches. The catalog is frozen.
3403
+
3404
+ | Function | What it does |
3405
+ |---|---|
3406
+ | `resolveCharacter(input, base?)` | The look an input describes. It starts from `base`, or from the class preset when there is no `base` or the input names another class, then applies the fields: `palette` sets all three colors, `colors` changes some, and the result's `palette` is set exactly when the colors equal a preset. Throws `CharacterError` for an unknown field or id or a bad color |
3407
+ | `parseCharacter(value)` | A complete spec from outside the process, checked strictly: an unknown key or id, a missing field, `skin: 6`, or a color that is not `#rrggbb` throws `CharacterError`. Colors are lowercased. Use it for an API body and for what a page receives |
3408
+ | `readCharacter(value)` | A spec from a stored row, read leniently and never throwing: a field this version does not know (written by a newer one) takes the class preset's value, an unknown key is ignored, and `undefined` comes back only when the value is not an object or names no known class |
3409
+ | `suggestCharacter(agent, taken?)` | A look for an agent that has none. Each class is scored by its keywords: the display name and the name count 3 each, the first 600 characters of the prompt 1. A tie or no hit is broken by a hash of the name among the classes `taken` does not use. `skin` and `build` come from separate hashes of the name, so a skin implies no body type, and the palette is the one `taken` uses least |
3410
+ | `composeSvg(spec, options?)` | The picture as an SVG string, see below |
3411
+ | `accentOf(spec, on)` | `colors.primary` as a UI accent: shaded until it reads at 3:1 on the page. `on` is a scheme (`light`: white, `dark`: `#0d1018`) or a look (`modern-light`, `modern-dark` as the schemes; `camelot-light`: the darkest parchment `#e3d2a9`, `camelot-dark`: the lightest night blue `#243557`) |
3412
+ | `characterCss()` | The text of `characters.css` |
3413
+
3414
+ `CharacterError.field` is the path of the offending field (`hat`, `colors.primary`).
3415
+
3416
+ ### The picture: `composeSvg`
3417
+
3418
+ `ComposeOptions` are all optional: `expr` (one of the 13 `EXPRESSIONS`, by default the one `STATE_EXPRESSION` gives the state), `state` (one of the seven `AVATAR_STATES`: `idle`, `thinking`, `working`, `waiting`, `done`, `error`, `sleepy`), `look` (`modern-light` by default, `modern-dark`, `camelot-light` or `camelot-dark`, which change only the background and ring), `frame` (`disc` or `shield`), `bare` (no frame or background), `still`, `uid`, `size` and `label`.
3419
+
3420
+ - The output has literal colors only. It has no `style` attribute, variable, `<style>`, script, text, image, link, event handler or `<foreignObject>`, so a page whose content security policy forbids inline styles can inject it, and `canvas` rasterizes it as it is. It is at most 8 KB and the same for the same input. The root is `<svg class="av" data-expr>` and the layers are `<g class="NAME" data-part="NAME">` in a fixed order (`frame`, `ch` holding `back`, `body`, `head`, `hat`, `eyes-brows`, `mouth`, `fx`, `acc`, `prop`, then `state`); the prop group carries `data-prop`.
3421
+ - `composeSvg` checks the spec again with `parseCharacter`, so a spec cast from untrusted data throws instead of reaching the markup. `uid` (`/^[a-z0-9]{1,16}$/`, default `a`) ends every clip path id, so each avatar on a page needs its own `uid`: two with the same one share clip path ids and one clips the other. The server-side renders pass an explicit one. `size` (an integer up to 4096) adds `width` and `height`, which `canvas` needs. `label` (at most 200 characters) becomes `aria-label` with `<>"'=()&` written as entities; without it the figure is `aria-hidden`.
3422
+ - A live figure carries every state overlay and leaves the motion to the stylesheet. With `still: true` only the `state`'s overlay is drawn and the figure has class `av static`, for files, PNGs and 3D textures; an `error` state turns the ring red in both.
3423
+
3424
+ ### Motion: `characters.css`
3425
+
3426
+ Import `pi-roundtable/characters.css` once. It breathes, tilts, bobs, leans, pops and shakes the figure by `data-state`, which you set on the `<svg>` or, so the markup does not change with the state, on a wrapper with class `av-host`. It moves only `transform` and `opacity`, keeps the pivots in selectors (`.ch`, `.eyes`, the badges and one rule per `[data-prop=<id>]`) since the markup carries no inline style, stops for `prefers-reduced-motion`, and stops on `.av.static`. The file is generated from `characterCss()` by `bun scripts/build-character-css.ts`, and a test fails when they differ.
3427
+
3428
+ ### Agent characters
3429
+
3430
+ Every agent of the agent server is a composed character. `Agent.character` is its resolved `CharacterSpec`, absent only on a row read before the team dressed it; `characterOf(agent)` returns it, or the look `suggestCharacter` gives its role. The spec is stored in the nullable `agents.character` jsonb column, which a migration of the agent server adds (`agents-character`), and `avatarHash` points at the PNG rendered from it, so Discord shows the character at `/avatars/<hash>.png`.
3431
+
3432
+ - `AgentSeed.character` (and `agents[].character` in the configuration) is a `CharacterInput` resolved over the look the role suggests: `{ class: "navigator", palette: "amber" }`. A bad field is a `ConfigError` that names it; a plugin seed with a bad `character` is a `PluginError` that names the plugin, the seed and the field. `avatarPrompt` is optional, deprecated and ignored; it is removed in 1.0.
3433
+ - At every start the team dresses each active agent that has no character: the seed of that name gives it, otherwise the suggestion for its role, spread over the palettes the team already wears. The picture is rendered and replaces the agent's old one, so **every existing agent's Discord picture changes at the first start of this version**. A failure is logged for that agent, which keeps its picture until the next start. A second start changes nothing.
3434
+ - A new agent's character is the `character` of its `agent_create` call over the suggestion for its role, and it is rendered before the agent is stored, so a render failure fails the create.
3435
+ - `AgentTeam.dress(name, input)` changes a look: the input is resolved over the agent's current character, rendered, and stored with the new picture in one update. A new `class` alone keeps the agent's colors; give a `palette` or `colors` to change them. A bad field is an `AgentError` that names it, as is an unknown or archived agent.
3436
+ - `AvatarStudio.render(spec)` renders a character as a 512 px PNG (the idle face on the modern light disc), stores it by content hash and returns the hash; the same spec gives the same hash. A plugin that implements `AvatarStudio` adds it. `draw`, `edit`, `fallback` and `canDraw` are deprecated until 1.0.
3437
+ - `renderCharacterPng(spec, options?)` (`pi-roundtable/kit`) does the same rasterization and returns the bytes. It needs `canvas` with SVG support (librsvg); the agent server renders a probe character at start and refuses to start when it cannot: the start fails as a `PluginError` whose `cause` is a `ConfigError` ("the agent server needs canvas with SVG support").
3438
+ - The agent tools and the Discord profile panel choose looks: `agent_create` takes `class` and `color`, `agent_avatar { name, class?, color?, parts? }` calls `AgentTeam.dress` on every host, and `/profile` has a class select (24) and a color select (8). `AgentTeam.redrawAvatar` and `AvatarMode` are deprecated, not removed; use `dress`. An old panel's redraw buttons answer that the panel is out of date.
3439
+ - Each avatar on one page needs its own `uid` in `composeSvg`; the server renders pass an explicit one.
3440
+
3187
3441
  ## Official plugins
3188
3442
 
3189
3443
  The package ships three plugins you can copy into a project and change.
@@ -3196,16 +3450,17 @@ The [pi-roundtable-mcp][mcp-package] workspace, published separately on npm, add
3196
3450
  The first lets the owner add MCP servers such as Notion or a calendar from Discord and gives your code the list; the second lets an agent outside Discord reach your agent.
3197
3451
  Install it with `bun add pi-roundtable-mcp`.
3198
3452
 
3199
- Seven official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
3453
+ Eight official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
3200
3454
  The [site guide source files][package-guides] describe installation, requirements, configuration, and security considerations:
3201
3455
 
3202
3456
  - [pi-roundtable-drawing][drawing-package]: [site guide source][drawing-guide] · local relationship maps, magic circles, sigils, sacred geometry, and card spreads.
3203
3457
  - [pi-roundtable-coding][coding-package]: [site guide source][coding-guide] · repository shelves and owner-approved Pi coding workers.
3204
- - [pi-roundtable-web][web-package]: [site guide source][web-guide] · an owner-only console for conversations, transcripts, and memory notes with live updates.
3458
+ - [pi-roundtable-web][web-package]: [site guide source][web-guide] · the owner app: rooms with the agent team on the web, approval cards, yolo and long-running modes, an installable PWA with Web Push, and a console for conversations, transcripts, and memory notes; see [the owner app](#the-owner-app-pi-roundtable-web).
3205
3459
  - [pi-roundtable-sandbox][sandbox-package]: [site guide source][sandbox-guide] · sealed guest channels on native Linux Docker with a host-only credential broker and allow-listed tools.
3206
3460
  - [pi-roundtable-mcp][mcp-package]: [site guide source][mcp-guide] · MCP connectors through a gateway and remote MCP endpoints for agent turns and owner-granted Discord channel tools.
3207
3461
  - [pi-roundtable-browser][browser-package]: [site guide source][browser-guide] · `browser_task`, a worker that drives agent-browser in a Docker container with no route to the host's private networks, and a `BrowserDriver` for another browser.
3208
3462
  - [pi-roundtable-webchat][webchat-package]: [site guide source][webchat-guide] · a WebSocket chat for people an OpenID Connect provider signs in, with private conversations, live progress, and approval cards; see [web chat](#web-chat-pi-roundtable-webchat).
3463
+ - [pi-roundtable-line][line-package]: [site guide source][line-guide] · a LINE Messaging API transport: a signed webhook, an allowlist of chats, one consumer, and pushes sent under a sender name, a retry key, and a quota check.
3209
3464
 
3210
3465
  [drawing-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/drawing/README.md
3211
3466
  [coding-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/coding/README.md
@@ -3213,6 +3468,7 @@ The [site guide source files][package-guides] describe installation, requirement
3213
3468
  [sandbox-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/sandbox/README.md
3214
3469
  [mcp-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/mcp/README.md
3215
3470
  [browser-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/browser/README.md
3471
+ [line-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/line/README.md
3216
3472
  [package-guides]: https://github.com/wayne930242/pi-roundtable/tree/master/site/src/content/docs/plugins/
3217
3473
  [drawing-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/drawing-package.mdx
3218
3474
  [coding-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/coding-package.mdx
@@ -3220,6 +3476,7 @@ The [site guide source files][package-guides] describe installation, requirement
3220
3476
  [sandbox-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/sandbox-package.mdx
3221
3477
  [mcp-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/mcp-package.mdx
3222
3478
  [browser-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/browser-package.mdx
3479
+ [line-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/line-package.mdx
3223
3480
  [webchat-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/webchat-package.mdx
3224
3481
  [coding-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/coding
3225
3482
  [sandbox-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
@@ -3323,6 +3580,7 @@ After setup, `conversations` routes to the plugin's claims, `surfaces` contains
3323
3580
  | `turns` | A `ConversationTurns` that replaces the default one |
3324
3581
  | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name: `{ "openai-codex": "key" }`. A provider not listed reads as having none, as on a host that is not logged in |
3325
3582
  | `forwardJoinMs` | How long the router holds a bare forward for the message that follows it (the host option `conversations.forwardJoinMs`) |
3583
+ | `listeners` | The addresses `context.listener(id)` reports, each `{ id, socketPath, mode? }` or `{ id, port, hostname? }`; an id not listed reads as `undefined` |
3326
3584
 
3327
3585
  The harness supplies what the host would, so a claim or a background turn behaves as it does there:
3328
3586
 
@@ -3413,7 +3671,7 @@ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins
3413
3671
  | `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
3414
3672
  | `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
3415
3673
  | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name; a provider not listed reads as having none, whatever login the machine holds |
3416
- | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)`, `ownerChannel()`, `channelInfo(channelId)` and `poster` (what `DISCORD.poster` is; by default a `fakeDiscordPoster()`); `false` boots a [host without Discord](#a-host-without-discord), with no `discord`, `http`, or `agents` in the test configuration |
3674
+ | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)`, `ownerChannel()`, `channelInfo(channelId)` and `poster` (what `DISCORD.poster` is; by default a `fakeDiscordPoster()`); `false` boots a [host without Discord](#a-host-without-discord), with no `discord`, `http`, or `agents` in the test configuration; give `config: { agents }` and a plugin whose surface declares `agentRooms` to boot an [agent team without Discord](#an-agent-team-without-discord) |
3417
3675
 
3418
3676
  It returns:
3419
3677
 
@@ -3493,7 +3751,7 @@ The host's `run()` handles startup:
3493
3751
  The runtime plugin builds the runtime later, in its own setup: from the `runtime` slot when a plugin fills it, else the Pi runtime.
3494
3752
  2. The database is opened and every plugin's migrations run, in plugin order.
3495
3753
  3. Every plugin's `setup` runs, in plugin order.
3496
- The order is the built-ins (`memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `worker-sessions`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`; an addon that is switched off is not there, and without Discord neither are `discord`, `discord-admin`, `skills`, `agent-server`, or `seeds`), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
3754
+ The order is the built-ins (`identity`, `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `worker-sessions`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`; an addon that is switched off is not there, and without Discord neither are `discord` or `discord-admin`, nor, without `agents`, `skills`, `agent-server` or `seeds`), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
3497
3755
  4. The contributions are linked: tool tiers, hold rules, the session plan, the channel router, and the events.
3498
3756
  From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
3499
3757
  5. Every plugin's `preflight` runs, in plugin order.
@@ -3527,13 +3785,27 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
3527
3785
  `shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
3528
3786
  Every call shares the same shutdown.
3529
3787
  Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
3530
- The built-in runtime limits a turn to 10 minutes of work, not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full 10 minutes again from that moment, up to a cap of 30 minutes of work for the whole turn; once the cap is reached the turn takes no more steering, so a message waits for the next turn.
3531
- When a limit fires on a turn that can show a card (an owner's turn or a report turn on a surface with cards, the same turns that can ask with `ask_user` or an approval card), the turn is not aborted: it keeps working and a card asks the owner whether to go on. The card names the limit, how long the turn has worked, what it is doing now (the tool running and for how long, or waiting on the model, or compacting the conversation) and the tools it called so far, by name and count, never their inputs. **Continue** gives the turn another full 10 minutes from the answer; at the cap it moves the cap too, to 10 minutes from the answer, so the owner can keep going in steps. **Cancel** aborts the turn. With no answer within 5 minutes of wall time the turn is aborted. The time the card is open counts as work, unlike an `ask_user` or approval card, because the turn keeps working; while it is open the turn takes no steering. If the turn finishes first, the card closes saying so and the reply is posted as usual; a press after that gets the usual "no longer valid" reply. The card's buttons come from `ApprovalOptions.wording` on `Prompts.confirm`, with `supersede` closing it, so a surface that shows approvals shows it; one that ignores `wording` shows its own approve and decline labels.
3788
+ The built-in runtime limits a turn to 10 minutes of work by default (`turnLimits.timeoutSeconds`), not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full budget again from that moment, up to a cap of 30 minutes of work for the whole turn by default (`turnLimits.maxSeconds`); once the cap is reached the turn takes no more steering, so a message waits for the next turn.
3789
+ When a limit fires on a turn that can show a card (an owner's turn or a report turn on a surface with cards, the same turns that can ask with `ask_user` or an approval card), the turn is not aborted: it keeps working and a card asks the owner whether to go on. The card names the limit, how long the turn has worked, what it is doing now (the tool running and for how long, or waiting on the model, or compacting the conversation) and the tools it called so far, by name and count, never their inputs. **Continue** gives the turn another full budget from the answer; at the cap it moves the cap too, to a full budget from the answer, so the owner can keep going in steps. **Cancel** aborts the turn. With no answer within `turnLimits.cardSeconds` of wall time (5 minutes by default) the turn is aborted. The time the card is open counts as work, unlike an `ask_user` or approval card, because the turn keeps working; while it is open the turn takes no steering. If the turn finishes first, the card closes saying so and the reply is posted as usual; a press after that gets the usual "no longer valid" reply. The card's buttons come from `ApprovalOptions.wording` on `Prompts.confirm`, with `supersede` closing it, and `ApprovalOptions.purpose` is `"turn-timeout"` on it (`"action"` or absent on a held call's approval), so a surface can tell the two apart and notify on the first; Discord ignores `purpose`. A surface that shows approvals shows it; one that ignores `wording` shows its own approve and decline labels.
3532
3790
  A turn that cannot show a card (a schedule's turn, a member's turn, a turn without a surface) is aborted at once, as before. Either way an aborted turn fails with a `TurnTimeoutError` (an `AgentRunError`) whose `timeout` (`TurnTimeout`) holds the limit, the work time, the running tools, whether it was compacting, the tools so far, and how it ended (`end`: `"timeout"`, `"cancelled"` by the owner, or `"unanswered"`); its message says the same in English, and the failure the conversation posts says it in the host's language instead of the generic failure notice.
3533
- The limits are fixed: the runtime plugin passes no option for them, nor for the card's grace period (`turnTimeoutMs`, `turnMaxMs` and `turnTimeoutCardMs` of `PiAgentRuntimeOptions`, for a host that builds the runtime itself), and a limit that waiting on an owner's card pushed back can fire up to a second late. The log says `steer renewed the turn's time budget` with `deadlineMs`, the work time left; `turn reached its time limit; asking the owner` when the card is posted; `owner continued the turn past its time limit` on Continue; and the warning `turn timed out; aborting` names the limit that fired (`limit: "turn"` or `"turnMax"`) with `workedMs`, `running`, `compacting`, `tools` and `end`. Task workers (`task.timeoutMs`), coding workers (`workTimeout`) and sandbox guests (their own `turnTimeoutMs`) keep their own limits, which steering does not renew and which post no card.
3791
+ The limits are the config's `turnLimits`, which the runtime plugin passes on to `turnTimeoutMs`, `turnMaxMs` and `turnTimeoutCardMs` of `PiAgentRuntimeOptions` (a host that builds the runtime itself sets those directly):
3792
+
3793
+ | Key | Default | What it is |
3794
+ |---|---|---|
3795
+ | `turnLimits.timeoutSeconds` | `600` | A turn's budget, 60 to 86400; each steered message renews it |
3796
+ | `turnLimits.maxSeconds` | three times `timeoutSeconds` | The most one turn works in all, at least `timeoutSeconds` |
3797
+ | `turnLimits.cardSeconds` | `300` | How long the card at a limit waits for an answer, 30 to 3600, in wall time |
3798
+
3799
+ A limit that waiting on an owner's card pushed back can fire up to a second late. The log says `steer renewed the turn's time budget` with `deadlineMs`, the work time left; `turn reached its time limit; asking the owner` when the card is posted; `owner continued the turn past its time limit` on Continue; and the warning `turn timed out; aborting` names the limit that fired (`limit: "turn"` or `"turnMax"`) with `workedMs`, `running`, `compacting`, `tools` and `end`. Task workers (`task.timeoutMs`), coding workers (`workTimeout`) and sandbox guests (their own `turnTimeoutMs`) keep their own limits, which steering does not renew and which post no card.
3534
3800
  A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
3535
3801
  `listen()`, called by the command line and a host's own entry point, exits the process with that code.
3536
3802
 
3803
+ ### Long-running conversations
3804
+
3805
+ An owner can switch a conversation to long-running mode, so a turn that reaches its limit goes on by itself instead of asking, or aborting when it cannot show a card. The server extends the limit exactly as **Continue** would, and logs `long-running mode continued the turn`. The cap is per turn: it counts the turn's work since it started, not the time spent on cards, and the extensions stop exactly at it (a `turnLimits.timeoutSeconds` of 3 hours with a 4-hour cap asks or aborts at 4 hours of work, not at 6). It is `autonomy.longRunning.maxSeconds` in the config (`600` to `604800`, default `14400`, four hours). At the cap the turn behaves as an ordinary one: a card whose `ApprovalOptions.purpose` is `"turn-timeout"`, so a surface can push it, or an abort. `ask_user` and approval cards are never answered automatically.
3806
+
3807
+ The mode is the conversation's, set with `MODES.set(channel, { longRunning: true }, speaker)` and read with `MODES.peek(channel)`; see [the `MODES` service](#conversation-modes-modes). A surface decides who may toggle it.
3808
+
3537
3809
  ## Errors and their fixes
3538
3810
 
3539
3811
  A mistake in a plugin or in the configuration stops the start, before Discord connects, with a message of the form `plugin <name>: <what>. <fix>.`
@@ -3584,7 +3856,7 @@ These are the messages as the code writes them, with `<...>` where your names go
3584
3856
  | `no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)` | Only a plugin that calls `providers.runtime` without `providers.filled.has("runtime")` sees it; read `services.get(RUNTIME)` instead |
3585
3857
  | `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
3586
3858
  | `plugin <name>: setup failed: the database was queried during setup…` (from `Roundtable.check()`, `roundtable doctor` or `start`) | A plugin's `setup` ran a query. Take `database()` in setup and query from a service's `start` or from a handler |
3587
- | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `worker-sessions`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`, and `schedules` |
3859
+ | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `identity`, `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `worker-sessions`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`, and `schedules` |
3588
3860
  | `migration <plugin>/<name> is declared twice` | A plugin declares two migrations with one name: rename one (the same name in two plugins is fine) |
3589
3861
  | `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
3590
3862
  | `route <name> is registered twice`, `routes <a> and <b> overlap on listener <id>` | Give each route its own name and a path no other route can take |
@@ -3745,7 +4017,9 @@ Import from the entries listed below; source area files are internal.
3745
4017
  | `ApprovedBy` | `pi-roundtable` | type |
3746
4018
  | `AskOption` | `pi-roundtable` | type |
3747
4019
  | `AskSupport` | `pi-roundtable` | type |
4020
+ | `AttachmentContent` | `pi-roundtable` | type |
3748
4021
  | `AttachmentFailure` | `pi-roundtable` | type |
4022
+ | `AttachmentFile` | `pi-roundtable` | type |
3749
4023
  | `AttachmentPort` | `pi-roundtable` | type |
3750
4024
  | `AttachmentRef` | `pi-roundtable` | type |
3751
4025
  | `AttachmentRefusal` | `pi-roundtable` | value |
@@ -3753,8 +4027,10 @@ Import from the entries listed below; source area files are internal.
3753
4027
  | `AttachmentRetention` | `pi-roundtable` | type |
3754
4028
  | `AttachmentUpload` | `pi-roundtable` | type |
3755
4029
  | `AttachmentsConfig` | `pi-roundtable` | type |
4030
+ | `AutoApproval` | `pi-roundtable` | type |
3756
4031
  | `AvatarMode` | `pi-roundtable` | type |
3757
4032
  | `AvatarStudio` | `pi-roundtable` | type |
4033
+ | `characterOf` | `pi-roundtable` | value |
3758
4034
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
3759
4035
  | `CONVERSATIONS` | `pi-roundtable` | value |
3760
4036
  | `BackgroundConfig` | `pi-roundtable` | type |
@@ -3765,6 +4041,7 @@ Import from the entries listed below; source area files are internal.
3765
4041
  | `ChannelClaim` | `pi-roundtable` | type |
3766
4042
  | `ChannelKey` | `pi-roundtable` | type |
3767
4043
  | `ChatSurface` | `pi-roundtable` | type |
4044
+ | `ClassedHold` | `pi-roundtable` | type |
3768
4045
  | `ClientCallError` | `pi-roundtable` | type |
3769
4046
  | `ClientCallOptions` | `pi-roundtable` | type |
3770
4047
  | `ClientCallResult` | `pi-roundtable` | type |
@@ -3774,6 +4051,8 @@ Import from the entries listed below; source area files are internal.
3774
4051
  | `ContextUse` | `pi-roundtable` | type |
3775
4052
  | `Contribution` | `pi-roundtable` | type |
3776
4053
  | `ConversationKind` | `pi-roundtable` | type |
4054
+ | `ConversationMode` | `pi-roundtable` | type |
4055
+ | `ConversationModes` | `pi-roundtable` | type |
3777
4056
  | `ConversationPort` | `pi-roundtable` | type |
3778
4057
  | `ConversationRecord` | `pi-roundtable` | type |
3779
4058
  | `ConversationRegistration` | `pi-roundtable` | type |
@@ -3782,6 +4061,7 @@ Import from the entries listed below; source area files are internal.
3782
4061
  | `ConversationTurnInput` | `pi-roundtable` | type |
3783
4062
  | `ConversationTurns` | `pi-roundtable` | type |
3784
4063
  | `DELEGATION` | `pi-roundtable` | value |
4064
+ | `DEFAULT_MAX_REQUEST_BODY_BYTES` | `pi-roundtable` | value |
3785
4065
  | `DefineOverrides` | `pi-roundtable` | type |
3786
4066
  | `DirectChannelProvider` | `pi-roundtable` | type |
3787
4067
  | `DirectChannels` | `pi-roundtable` | type |
@@ -3832,10 +4112,14 @@ Import from the entries listed below; source area files are internal.
3832
4112
  | `ListenerConfig` | `pi-roundtable` | type |
3833
4113
  | `LoadedSkill` | `pi-roundtable` | type |
3834
4114
  | `Locale` | `pi-roundtable` | type |
4115
+ | `LimitsConfig` | `pi-roundtable` | type |
3835
4116
  | `LogEntry` | `pi-roundtable` | type |
3836
4117
  | `LogFn` | `pi-roundtable` | type |
3837
4118
  | `Logger` | `pi-roundtable` | type |
4119
+ | `LOCAL_TEAM_ID` | `pi-roundtable` | value |
3838
4120
  | `MEMORY` | `pi-roundtable` | value |
4121
+ | `MAX_ATTACHMENT_BYTES` | `pi-roundtable` | value |
4122
+ | `MODES` | `pi-roundtable` | value |
3839
4123
  | `MEMORY_KINDS` | `pi-roundtable` | value |
3840
4124
  | `Memory` | `pi-roundtable` | type |
3841
4125
  | `MemoryError` | `pi-roundtable` | value |
@@ -3844,6 +4128,7 @@ Import from the entries listed below; source area files are internal.
3844
4128
  | `Migration` | `pi-roundtable` | type |
3845
4129
  | `MigrationError` | `pi-roundtable` | value |
3846
4130
  | `MigrationReport` | `pi-roundtable` | type |
4131
+ | `ModeChange` | `pi-roundtable` | type |
3847
4132
  | `ModelImage` | `pi-roundtable` | type |
3848
4133
  | `NO_ATTACHMENTS` | `pi-roundtable` | value |
3849
4134
  | `NewSchedule` | `pi-roundtable` | type |
@@ -3851,6 +4136,7 @@ Import from the entries listed below; source area files are internal.
3851
4136
  | `OWNER_TARGET` | `pi-roundtable` | value |
3852
4137
  | `PERSONAL_TARGET` | `pi-roundtable` | value |
3853
4138
  | `OutboundReply` | `pi-roundtable` | type |
4139
+ | `PostAuthor` | `pi-roundtable` | type |
3854
4140
  | `OwnerAnswer` | `pi-roundtable` | type |
3855
4141
  | `OwnerPrompts` | `pi-roundtable` | type |
3856
4142
  | `OwnerQuestion` | `pi-roundtable` | type |
@@ -3937,8 +4223,10 @@ Import from the entries listed below; source area files are internal.
3937
4223
  | `SpeakerMemory` | `pi-roundtable` | type |
3938
4224
  | `StoredAttachment` | `pi-roundtable` | type |
3939
4225
  | `SurfacePort` | `pi-roundtable` | type |
4226
+ | `SurfaceThreads` | `pi-roundtable` | type |
3940
4227
  | `SYSTEM_PRINCIPAL` | `pi-roundtable` | value |
3941
4228
  | `THE_SPEAKER` | `pi-roundtable` | value |
4229
+ | `THREADS` | `pi-roundtable` | value |
3942
4230
  | `TIERS` | `pi-roundtable` | value |
3943
4231
  | `TeamAgentStatus` | `pi-roundtable` | type |
3944
4232
  | `TeamStatus` | `pi-roundtable` | type |
@@ -3977,6 +4265,9 @@ Import from the entries listed below; source area files are internal.
3977
4265
  | `WebSocketSendResult` | `pi-roundtable` | type |
3978
4266
  | `Weekday` | `pi-roundtable` | type |
3979
4267
  | `attachReplyFile` | `pi-roundtable` | value |
4268
+ | `agentRoomKey` | `pi-roundtable` | value |
4269
+ | `groupRoomKey` | `pi-roundtable` | value |
4270
+ | `roomOf` | `pi-roundtable` | value |
3980
4271
  | `promptScope` | `pi-roundtable` | value |
3981
4272
  | `validateQuestionnaire` | `pi-roundtable` | value |
3982
4273
  | `withReplyFiles` | `pi-roundtable` | value |
@@ -3984,6 +4275,7 @@ Import from the entries listed below; source area files are internal.
3984
4275
  | `prepareImageBytes` | `pi-roundtable/kit` | value |
3985
4276
  | `ImagePreparationError` | `pi-roundtable/kit` | value |
3986
4277
  | `squarePng` | `pi-roundtable/kit` | value |
4278
+ | `renderCharacterPng` | `pi-roundtable/kit` | value |
3987
4279
  | `SQUARE_PNG_MAX_SIDE` | `pi-roundtable/kit` | value |
3988
4280
  | `SquarePngOptions` | `pi-roundtable/kit` | type |
3989
4281
  | `scrubDiagnostic` | `pi-roundtable/kit` | value |
@@ -4110,6 +4402,7 @@ Import from the entries listed below; source area files are internal.
4110
4402
  | `SpeakerFacts` | `pi-roundtable/kit` | type |
4111
4403
  | `SpeakerPolicy` | `pi-roundtable/kit` | type |
4112
4404
  | `THINKING_LEVELS` | `pi-roundtable/kit` | value |
4405
+ | `YOLO_CARDED_SHELL_CLASSES` | `pi-roundtable/kit` | value |
4113
4406
  | `TextToolDef` | `pi-roundtable/kit` | type |
4114
4407
  | `ThinkingPicker` | `pi-roundtable/kit` | type |
4115
4408
  | `ThreadHost` | `pi-roundtable/kit` | type |
@@ -4271,3 +4564,32 @@ Import from the entries listed below; source area files are internal.
4271
4564
  | `replyWithPanels` | `pi-roundtable/discord` | value |
4272
4565
  | `withChannelContext` | `pi-roundtable/discord` | value |
4273
4566
  | `withChannelContextImages` | `pi-roundtable/discord` | value |
4567
+ | `AVATAR_STATES` | `pi-roundtable/characters` | value |
4568
+ | `CATALOG` | `pi-roundtable/characters` | value |
4569
+ | `CLASS_IDS` | `pi-roundtable/characters` | value |
4570
+ | `CharacterError` | `pi-roundtable/characters` | value |
4571
+ | `EXPRESSIONS` | `pi-roundtable/characters` | value |
4572
+ | `STATE_EXPRESSION` | `pi-roundtable/characters` | value |
4573
+ | `accentOf` | `pi-roundtable/characters` | value |
4574
+ | `characterCss` | `pi-roundtable/characters` | value |
4575
+ | `composeSvg` | `pi-roundtable/characters` | value |
4576
+ | `parseCharacter` | `pi-roundtable/characters` | value |
4577
+ | `readCharacter` | `pi-roundtable/characters` | value |
4578
+ | `resolveCharacter` | `pi-roundtable/characters` | value |
4579
+ | `suggestCharacter` | `pi-roundtable/characters` | value |
4580
+ | `AccessoryId` | `pi-roundtable/characters` | type |
4581
+ | `AvatarState` | `pi-roundtable/characters` | type |
4582
+ | `BeardId` | `pi-roundtable/characters` | type |
4583
+ | `CharacterColors` | `pi-roundtable/characters` | type |
4584
+ | `CharacterInput` | `pi-roundtable/characters` | type |
4585
+ | `CharacterLook` | `pi-roundtable/characters` | type |
4586
+ | `CharacterSpec` | `pi-roundtable/characters` | type |
4587
+ | `ClassId` | `pi-roundtable/characters` | type |
4588
+ | `ComposeOptions` | `pi-roundtable/characters` | type |
4589
+ | `Expression` | `pi-roundtable/characters` | type |
4590
+ | `HairId` | `pi-roundtable/characters` | type |
4591
+ | `HatId` | `pi-roundtable/characters` | type |
4592
+ | `Hex` | `pi-roundtable/characters` | type |
4593
+ | `OutfitId` | `pi-roundtable/characters` | type |
4594
+ | `PaletteName` | `pi-roundtable/characters` | type |
4595
+ | `PropId` | `pi-roundtable/characters` | type |