pi-roundtable 0.27.1 → 0.29.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 (65) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +54 -22
  5. package/package.json +1 -1
  6. package/src/characters/compose.ts +11 -0
  7. package/src/characters/parts-face.ts +23 -2
  8. package/src/core/agents/agent-messages.ts +37 -92
  9. package/src/core/agents/agent-ports.ts +3 -0
  10. package/src/core/agents/agent-prompt.ts +2 -16
  11. package/src/core/agents/agent-rules.ts +27 -0
  12. package/src/core/agents/agent-team.ts +5 -0
  13. package/src/core/agents/agent-tools.ts +18 -10
  14. package/src/core/agents/helper-create.ts +3 -24
  15. package/src/core/agents/helper-modes.ts +64 -0
  16. package/src/core/agents/message-target.ts +20 -18
  17. package/src/core/agents/routes.ts +42 -24
  18. package/src/core/agents/team-editing.ts +5 -5
  19. package/src/core/agents/team-helpers.ts +76 -5
  20. package/src/core/agents/team-keys.ts +14 -0
  21. package/src/core/agents/team-turn-types.ts +7 -13
  22. package/src/core/agents/team-turns.ts +9 -6
  23. package/src/core/builtin/agent-server.ts +3 -4
  24. package/src/core/builtin/discord.ts +35 -8
  25. package/src/core/builtin/modules.ts +15 -15
  26. package/src/core/config/adapters.ts +2 -1
  27. package/src/core/config/limits.ts +35 -2
  28. package/src/core/config/routing.ts +5 -4
  29. package/src/core/discord/agent-discord.ts +12 -1
  30. package/src/core/discord/card-copies.ts +7 -2
  31. package/src/core/discord/direct-channel.ts +1 -0
  32. package/src/core/discord/discord-surface.ts +13 -30
  33. package/src/core/discord/intercom-cards.ts +121 -0
  34. package/src/core/discord/owner-card-asks.ts +9 -0
  35. package/src/core/discord/owner-card-types.ts +2 -0
  36. package/src/core/discord/owner-cards.ts +2 -1
  37. package/src/core/discord/stop-button.ts +35 -0
  38. package/src/core/domain/conversation.ts +18 -0
  39. package/src/core/domain/interim.ts +7 -0
  40. package/src/core/domain/turn-timeout.ts +5 -0
  41. package/src/core/i18n/conversation.ts +16 -4
  42. package/src/core/i18n/turn-timeout.ts +10 -2
  43. package/src/core/interactions/prompts.ts +5 -0
  44. package/src/core/modes/autonomy.ts +22 -1
  45. package/src/core/modes/conversation-modes.ts +135 -23
  46. package/src/core/modes/mode-inheritance.ts +78 -0
  47. package/src/core/modes/modes-plugin.ts +1 -1
  48. package/src/core/modules/notify/notify.ts +3 -2
  49. package/src/core/modules/notify/session-notify.ts +21 -4
  50. package/src/core/modules/skills/skill-contract.ts +13 -0
  51. package/src/core/modules/skills/skill-registry.ts +3 -1
  52. package/src/core/modules/skills/skill-store.ts +4 -3
  53. package/src/core/presence/delivery-router.ts +165 -0
  54. package/src/core/presence/direct-channels.ts +39 -68
  55. package/src/core/presentation/intercom.ts +44 -0
  56. package/src/core/runtime/ask-questions.ts +21 -4
  57. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  58. package/src/core/runtime/extensions/ask-user.ts +21 -1
  59. package/src/core/runtime/interim-text.ts +7 -4
  60. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  61. package/src/core/runtime/prompt-slot.ts +11 -1
  62. package/src/core/runtime/session-factory.ts +11 -6
  63. package/src/core/runtime/turn-budget.ts +34 -3
  64. package/src/core/runtime/turn-limits.ts +13 -3
  65. package/src/index.ts +7 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.29.0] - 2026-10-31
9
+
10
+ ### Added
11
+
12
+ - `SkillRegistry.groups()`, `setGroup(name, description, skills)` and `deleteGroup(name)` are part of the skill registry interface (`SkillGroup`: `name`, `description`, `skills`), so a host surface can manage skill groups. A group stays a filter and a list to apply once: an agent carries skills, not groups. `SkillRegistry` has three new required methods, so a hand-built stand-in must set them.
13
+ - `helperSurface` tells the surface a temp knight was made on, read off its stored origin. `AgentOps.reach` readies a message's place before `message_agent` delivers it.
14
+
15
+ ### Changed
16
+
17
+ - Delivery follows the conversation the work came from. One delivery router (`src/core/presence/delivery-router.ts`) decides where a notice or run for a person goes from its origin: a provider on the origin's surface first, another surface only when none there reaches the person, logged with both surfaces. A schedule made in a web room that calls `notify` now notices in the web app (Home notice and push) instead of a Discord direct message. The web app's push mirror of a notice Discord delivered stays, to the same person only.
18
+ - Breaking: `DirectChannels.reach`, `known`, and `notify` take the origin `ChannelKey` first (`reach(origin, principalId)`, `notify(origin, principalId, text)`), and `DirectChannels` no longer extends `Notifier`. `DirectChannelProvider.surface` (optional, default `name`) names the surface a provider reaches; the Discord, web, and webchat providers set it.
19
+ - `message_agent` without `conversation` reaches the target's place on the sender's surface (`routing.agents.<name>.agentMessage` default `origin-surface`), never the conversation it used most recently on another surface; with no place there it lands in the primary place, logged. `latest` now follows only on the sender's surface. `RoutingTable.of(agent, "agentMessage", origin)` requires the origin.
20
+ - The notify tool's description says it goes out on the conversation's own surface.
21
+ - A temp knight lives only on the surface of the conversation that made it. One made in the web app gets no Discord thread at creation; the thread is made once, when a `message_agent` call from a turn that may message it names `conversation: "discord"`, or names no conversation from a Discord turn (where an unnamed message goes), and restoring it reopens the thread it had but never makes a new one. Archiving one without a thread works as before. A temp knight made before its origin was kept is placed as before. See pi-roundtable-web for the rooms of temp knights made on Discord.
22
+
23
+ ### Fixed
24
+
25
+ - A group seat outside its running turn, such as after a start-over, stays in the group's room on its own surface instead of falling out to the group's Discord channel.
26
+
27
+ ## [0.28.0] - 2026-10-31
28
+
29
+ ### Added
30
+
31
+ - The web console's Connectors page manages the owner's MCP connectors (list, add with a write-only token, change a purpose, remove) and the MCP channel authorizations (bundles with their channels, operations and recent audit; authorize, rename, revoke, and a new bundle URL shown once). See pi-roundtable-web and pi-roundtable-mcp.
32
+ - Yolo mode: an `ask_user` card holds the turn only `autonomy.yolo.questionGraceSeconds` (default 30 s); unanswered, the agent goes on with its recommended option, says what it assumed, and ends the turn only for a truly blocking question, while the card stays open for a late answer. The timeout card waits `autonomy.yolo.timeoutCardSeconds` (default 60 s) and an unanswered one continues the turn up to the long-running cap (`TurnTimeout.autoContinue`). `PromptWait.graceMs` carries a turn's own wait to surfaces. A host that is stopping still posts no card.
33
+ - Temp knights follow the yolo and long-running modes of the conversation that made them, live: their own modes cannot be switched, an origin that is gone reads off, and only an owner's turn makes a temp knight follow (`ConversationModes.follow`, `forget`, `trackOrigins`, `ConversationMode.inheritedFrom`, migration `conversation-modes-follows`).
34
+ - Discord keeps intercom cards' full texts in `intercom-texts.json` (newest 300, following `discord.cardCopies`), opened ephemerally with **Show all**.
35
+ - The character kit's `composeSvg` takes `mouth: "open" | "closed"` for the frames of a talking figure.
36
+ - `SkillRegistry.delete(name)` is part of the skill registry interface, so a host surface can remove an agent-written skill as `skill_delete` does.
37
+
38
+ ### Changed
39
+
40
+ - `message_agent` opens no thread: the exchange shows in the sender's conversation as small intercom cards (`IntercomCard`, `OutboundReply.intercom`, `AgentPost.intercom`) that open to the whole text. `TeamTurnsOptions.threads` is gone.
41
+ - `InterimPosts.tools: false` leaves the tool line out of the progress message; the web app's rooms set it, Discord keeps the line.
42
+
43
+ ### Fixed
44
+
45
+ - `ask_user` posts no card while the host stops: open cards live in the surface's memory and do not survive the restart, so the tool now tells the turn to ask in its reply instead. A card still waiting when the drain begins is reported lost rather than "stays open".
46
+
8
47
  ## [0.27.1] - 2026-10-31
9
48
 
10
49
  ### Fixed
package/README.md CHANGED
@@ -192,6 +192,8 @@ A turn works 10 minutes by default before it asks you whether to go on, and 30 m
192
192
  | `turnLimits.cardSeconds` | `300` | How long that card waits for an answer, 30 to 3600; with none, the turn stops |
193
193
  | `autonomy.longRunning.maxSeconds` | `14400` (4 h) | The most a long-running conversation's turn works before it asks or stops, 600 to 604800 |
194
194
  | `autonomy.yolo.allow`, `autonomy.yolo.card` | none | Hold classes yolo mode may approve beyond its defaults (`shell.rm`, say), and rule names whose calls it approves although the rule reports no classes (`tool:repo_push`, say), and classes or rule names it never approves (`shell.push`, say). By default yolo approves a plain `git push` and writes outside the workspace and cards the other destructive shell classes; see the [plugin guide](docs/plugins.md#hold-classes-and-yolo-mode) |
195
+ | `autonomy.yolo.questionGraceSeconds` | `30` | In a yolo conversation, how long an `ask_user` card holds the turn, 5 to 600; unanswered, the turn goes on with its own best choice and the card stays open |
196
+ | `autonomy.yolo.timeoutCardSeconds` | `60` | In a yolo conversation, how long the timeout card waits, 10 to 3600; unanswered, the turn goes on as if you pressed Continue, up to `autonomy.longRunning.maxSeconds` |
195
197
 
196
198
  Without `discord` the agents still run when `agents` lists them, in the rooms of a surface that hosts them, such as pi-roundtable-web; the host refuses to start when none does:
197
199
 
package/README.zh-TW.md CHANGED
@@ -187,6 +187,8 @@ export default {
187
187
  | `turnLimits.cardSeconds` | `300` | 那張卡片等你回答多久,30 到 3600;時間到沒回答,這一輪就停止 |
188
188
  | `autonomy.longRunning.maxSeconds` | `14400`(4 小時) | 長時間執行對話的一輪,工作到這個上限才會詢問或停止,600 到 604800 |
189
189
  | `autonomy.yolo.allow`、`autonomy.yolo.card` | 無 | yolo 模式在預設之外可以核准的 hold 類別(例如 `shell.rm`),以及它也核准其呼叫的規則名稱(該規則沒有回報類別,例如 `tool:repo_push`),以及它永遠不核准的類別或規則名稱(例如 `shell.push`)。預設下 yolo 會核准一般的 `git push` 和工作區以外的寫入,其餘破壞性的 shell 類別仍然出卡;見[外掛指南](docs/plugins.md#hold-classes-and-yolo-mode) |
190
+ | `autonomy.yolo.questionGraceSeconds` | `30` | yolo 對話裡,`ask_user` 卡片讓這一輪等多久,5 到 600;沒回答時,這一輪照它自己的最佳選擇繼續,卡片仍然開著 |
191
+ | `autonomy.yolo.timeoutCardSeconds` | `60` | yolo 對話裡,逾時卡片等多久,10 到 3600;沒回答時,這一輪就像按了「繼續」一樣接著做,最多到 `autonomy.longRunning.maxSeconds` |
190
192
 
191
193
  沒有 `discord` 時,只要 `agents` 列了 agent,它們就在支援房間的 surface(例如 pi-roundtable-web)裡運作;沒有任何 surface 支援房間時 host 拒絕啟動:
192
194
 
package/docs/plugins.md CHANGED
@@ -182,7 +182,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
182
182
  | `surfaces` | Every contributed [chat surface](#surfaces-a-chat-network-of-your-own), chosen by the prefix of a channel key: `of`, `sendReply`, `startTyping`, `showStop`, `react`, `unreact`, `prompts` |
183
183
  | `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
184
184
  | `observe(filter, observer)` | Hears inbound messages without owning or admitting them: [`observe`](#observe-hearing-messages-without-owning-them). Returns a function that stops it; may be called during `setup` |
185
- | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(principalId)`, `known(principalId)`, and `notify(principalId, text)` |
185
+ | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(origin, principalId)`, `known(origin, principalId)`, and `notify(origin, principalId, text)`, each through the [delivery router](#where-things-are-delivered) |
186
186
  | `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
187
187
 
188
188
  `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, and `dashboard()` are available from a service's `start`, an event handler, a session tool, or a claim's turn.
@@ -258,7 +258,7 @@ The built-in plugins provide these, from the main entry:
258
258
  | `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 |
259
259
  | `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), `avatars` (`AvatarStudio`), `workspace` (`AgentWorkspace`: the agents' `workDir` and, when set, `scratchDir`), and `routes` (`RoutingTable`: where work no person started lands; see [Routing](#routing-where-work-no-person-started-lands)) |
260
260
  | `PROMPTS` | `PromptDocuments` | `prompts` | The prompt texts the owner edits in the app, each with a file version, an override and a history: `register`, `text`, `list`, `get`, `set`, `revert`, `history`. Always on; read it with `get`. See [`PROMPTS`: prompt texts the owner edits](#prompts-prompt-texts-the-owner-edits) |
261
- | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory` |
261
+ | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory`, and the groups `groups`, `setGroup`, `deleteGroup` |
262
262
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
263
263
  | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
264
264
  | `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` |
@@ -269,7 +269,7 @@ The built-in plugins provide these, from the main entry:
269
269
  | `ChannelContextSourceAttachment` | `pi-roundtable` | type |
270
270
  | `ChannelContextVerdict` | `pi-roundtable` | type |
271
271
  | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
272
- | `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) |
272
+ | `THREADS` | `DispatchThreads` | `threads` | The threads of background dispatches (coding tasks, delegated tasks), 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) |
273
273
  | `CHANNEL_CONTEXT` | `ChannelContexts` | `channel-context` | `read(message, options?)` gives the context of one addressed message, from the surface whose key prefix its channel carries: the messages posted there since the assistant last posted, or `undefined` for a surface without `channelContext`, a channel that is not shared, and when nothing new was said. See [Channel context](#channel-context-what-was-said-around-an-addressed-message) |
274
274
  | `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) |
275
275
 
@@ -459,7 +459,7 @@ A conversation can run with less supervision when its owner switches a mode on.
459
459
  | Mode | Effect |
460
460
  |---|---|
461
461
  | `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) |
462
- | `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 |
462
+ | `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. The cards it still posts do not stop the work: see [cards in a yolo conversation](#cards-in-a-yolo-conversation) |
463
463
 
464
464
  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:
465
465
 
@@ -473,6 +473,12 @@ modes.peek(channel).longRunning; // true
473
473
 
474
474
  `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.
475
475
 
476
+ #### Modes a temp knight inherits
477
+
478
+ A temp knight's conversations (its Discord thread and its rooms) follow the yolo and long-running modes of the conversation that made it, live: `helper_create` records them with `follow(channels, origin, by)`, and `peek` on each returns the origin's modes with `ConversationMode.inheritedFrom` (`{ channel, name?, gone? }`, `name` the knight's display name). Switching a mode in the knight's conversation applies to its temp knights at once, and `onChange` tells each of their conversations too. `set` refuses a conversation that follows another (`PluginError`), so its modes are switched only where they come from; the web room's banner says "Inherited from <knight>" in place of its switches, and a switch in its menu is refused with that reason. When the origin's agent or group is archived or deleted, the temp knight's modes read off (`gone: true`). The follow is stored in the `follows` column of `conversation_modes` (the migration `conversation-modes-follows`), so it survives a restart; the agent server tells the modes which conversations still follow theirs with `trackOrigins`, so a name an agent takes later follows nothing. A yolo approval in a temp knight's turn names its own conversation in `autoApproved`.
479
+
480
+ Inheriting adds no permission: an owner turned the modes on in the origin. `follow` takes an owner, as `set` does, so only an owner's turn makes a temp knight follow; a temp knight that a member's turn, or the host's own, made follows nothing and starts with both modes off (`forget(channel)` drops a conversation's modes and what it follows, and needs no speaker since it only turns modes off). A temp knight made before 0.28 keeps the long-running mode it was given.
481
+
476
482
  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).
477
483
 
478
484
  ## Principals and access
@@ -1116,6 +1122,15 @@ export default {
1116
1122
 
1117
1123
  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.
1118
1124
 
1125
+ #### Cards in a yolo conversation
1126
+
1127
+ Yolo mode still posts the two kinds of cards meant for a person, but neither stops autonomous work when no one answers:
1128
+
1129
+ - An `ask_user` card holds the turn `autonomy.yolo.questionGraceSeconds` at most (`5` to `600`, default `30`; `Autonomy.yolo.questionGraceMs`), passed to the surface as `PromptWait.graceMs`, so Discord and the web app wait that long instead of their own grace. Unanswered, the tool tells the model to keep working: go on with its recommended option, or its best judgment, and say in its reply what it assumed. The card stays open, and a later answer starts a new turn to correct course. Only a question that truly blocks (no safe choice without the answer, such as an irreversible or outward-facing step whose direction only the person can pick) may end the turn, after the work the answer does not affect is done. The turn's system prompt carries the same rule, so the model does not end a yolo turn with a question either.
1130
+ - The timeout card waits `autonomy.yolo.timeoutCardSeconds` (`10` to `3600`, default `60`; `Autonomy.yolo.timeoutCardGraceMs`), says that with no answer the turn keeps going, and, unanswered, extends the turn as **Continue** would; its closed card says it continued automatically, and the log says `yolo mode continued the turn; the timeout card went unanswered`. The extensions stop at `autonomy.longRunning.maxSeconds` of work: there the card is the usual one and no answer stops the turn. **Cancel** still stops it. Long-running mode, which extends without a card, is unchanged.
1131
+
1132
+ The mode is read when the card is asked for, so a toggle applies to the next card. While the host stops, `ask_user` posts no card and reports a waiting one lost, in yolo as outside it. Outside yolo nothing changes.
1133
+
1119
1134
  **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.
1120
1135
 
1121
1136
  #### The shell rule
@@ -2984,6 +2999,7 @@ setup: ({ services }) => ({
2984
2999
  directChannels: [
2985
3000
  {
2986
3001
  name: "inbox",
3002
+ surface: "chat",
2987
3003
  label: "a notice in the web chat's inbox",
2988
3004
  reaches: async (principalId) => inboxOf(principalId),
2989
3005
  deliver: async (principalId, text) => postNotice(principalId, text),
@@ -2995,23 +3011,36 @@ setup: ({ services }) => ({
2995
3011
  | Field | What it is |
2996
3012
  |---|---|
2997
3013
  | `name` | Unique across every plugin; a second provider of one name is a `PluginError` naming both plugins |
3014
+ | `surface` | Optional: the surface whose conversations it reaches, the key prefix of the channels `reaches` names, such as `discord` or the web app's `app`. Work from a conversation on that surface reaches the person here first. Without it, `name` |
2998
3015
  | `label` | How the `notify` tool names a notice sent through it, completing "Send Ada …", such as `a direct message on Discord` |
2999
3016
  | `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 |
3000
3017
  | `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` |
3001
3018
  | `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 |
3002
- | `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 |
3019
+ | `mirror(principalId, text)` | Optional: echoes a notice another provider delivered to the same person, 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 |
3003
3020
 
3004
- The host reaches a person through the first provider, in contribution order, that reaches them.
3005
- `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.
3021
+ Every lookup takes the *origin*: the conversation the work came from. The host reaches a person through a provider on the origin's surface, and through another only when none there reaches them, as [Where things are delivered](#where-things-are-delivered) describes.
3022
+ `context.directChannels.reach(origin, principalId)` resolves that provider and channel, or `undefined`; `known(origin, principalId)` resolves the provider that knows the person without the network, in the same order; `notify(origin, principalId, text)` sends a notice there and resolves `false` when no provider reaches the person.
3006
3023
  The Discord plugin contributes one named `discord`: it finds a principal's Discord identities through `IDENTITY` and reaches the first one's direct messages, the primary owner's configured identity first, so a single owner is reached in the same direct messages as in 0.8.
3007
3024
 
3008
3025
  The `notify` tool, 0.8's `notify_owner`, sends such a notice. A session has it only when a provider is contributed, and a private conversation only when a provider knows its person; the channel itself is reached when a notice is sent.
3009
3026
  It notifies the conversation's person in a private conversation and the turn's speaker in a shared one, and refuses when no provider reaches them or when the speaker is not the private conversation's person; the host's own turns, such as a report's, notify the conversation's person in a private conversation and the primary owner elsewhere, as `notify_owner` did.
3010
- Its description names the channels that can reach the person, so a host with only Discord reads `Send Ada a direct message on Discord. …` as 0.8 did.
3027
+ It sends on the conversation's own surface: from a web room the web app's notice and push, from Discord a Discord direct message. Its description names the channel on that surface that can reach the person, and says it goes out on the conversation's own surface, such as `Send Ada a direct message on Discord. It goes out on this conversation's own surface. …`.
3011
3028
  In a shared conversation it names the primary owner while only they can receive a notice (the host has no other owner, and only owners may use it), and "the speaker" otherwise; this is checked again before each turn, so an open session follows an owner granted or revoked meanwhile.
3012
3029
  Until 1.0 the name `notify_owner` still selects it in a selection or a profile and names it in a task's `exclude` and a plugin's `requiredTools`, and an operator's or a plugin's `toolTiers` entry for `notify_owner` applies to `notify` unless `notify` has its own.
3013
3030
  Using the old name logs a deprecation warning once per logger, whether in a selection or in `toolTiers`.
3014
3031
 
3032
+ ### Where things are delivered
3033
+
3034
+ Everything the host sends goes back where the work came from. Every turn knows its *origin*, the conversation it runs in (a `ChannelKey` whose prefix is its surface), and so does everything a turn leaves behind: a schedule keeps the conversation it was made in, a delegated or coding job its channel and origin, a kept agent message its target's conversation and the sender's, a dispatch thread its parent. Nothing chooses a destination from a global default: not the first provider contributed, not the conversation an agent used most recently, not an agent's Discord channel.
3035
+
3036
+ - **Replies, cards, and threads** go through `SurfacePort`, which picks the surface by the channel key's prefix: an `ask_user` card, a timeout card, a message's request and answer cards, a job's thread and report all land in the origin's conversation, on its surface.
3037
+ - **The delivery router** (`src/core/presence/delivery-router.ts`) is the one place that decides where a notice or run for a *person* goes. Given the origin, it tries the direct channels on the origin's surface first: a web conversation reaches the web app's channel (a Home notice and a push), a Discord conversation a Discord direct message. Only when no provider on that surface reaches the person does it try another surface, and it logs that fallback with both surfaces (`from`, `to`). `notify`, `context.directChannels`, and the run channel of a schedule or delegated task made in a conversation no chat surface carries (`runChannelFor`) all go through it.
3038
+ - **Mirroring** is the provider's own declaration: after a delivery, each other provider that implements `mirror` echoes the notice to the *same person*, such as the web app pushing to their phone a notice Discord delivered. It never sends to anyone else and never posts in a conversation.
3039
+ - **`message_agent`** without `conversation` reaches the target's place on the *sender's* surface: from a web room, the target's room on that surface; from Discord, the target's Discord channel. When the target has no place there, it lands in the target's primary place, and the crossing is logged. A routing rule may pin it elsewhere; `latest` follows the conversation the target last used only on the sender's surface.
3040
+ - **A group seat** outside its running turn, such as after a start-over, posts in the group's room on its own surface and never falls out to the group's Discord channel.
3041
+
3042
+ `scripts/delivery-scan.test.ts` fails the build when code outside the router asks a provider to reach someone, walks the providers to pick one, or reads an agent's Discord channel id outside the files listed there; `src/core/presence/cross-surface.test.ts` starts each outbound feature from a web and from a Discord conversation and checks the other surface received nothing.
3043
+
3015
3044
  ### `surfaces`: a chat network of your own
3016
3045
 
3017
3046
  A chat surface connects the host to one chat network: it reports what people write, and it posts what the conversations answer.
@@ -3027,7 +3056,7 @@ The agent server claims `discord:` keys, and, only on a surface that declares `a
3027
3056
  |---|---|---|
3028
3057
  | `surface` | The key prefix | (required) |
3029
3058
  | `start(deliver)` | Connects, and calls `deliver(message)` for every incoming message; the host passes its conversation router | (required) |
3030
- | `sendReply(channel, reply)` | Posts an `OutboundReply`: a thinking line, a card, text chunks, then files | (required) |
3059
+ | `sendReply(channel, reply)` | Posts an `OutboundReply`: a thinking line, a card, text chunks, then files; a reply with `intercom` is a message between agents, which a surface may draw as a small card in place of its chunks | (required) |
3031
3060
  | `stop()` | Disconnects when the host stops | nothing to stop |
3032
3061
  | `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 |
3033
3062
  | `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 |
@@ -3038,7 +3067,7 @@ The agent server claims `discord:` keys, and, only on a surface that declares `a
3038
3067
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)`. A group's round shows it for the whole round, and stopping ends the round: the member speaking is stopped and the members still waiting do not speak | none is shown |
3039
3068
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
3040
3069
  | `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 |
3041
- | `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 |
3070
+ | `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. `tools: false` leaves the tool line out of the progress message, for a surface that shows tool use elsewhere. `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 |
3042
3071
  | `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 |
3043
3072
  | `groupRound(channel, round)` | Shows where a group room's round stands: a `RoundProgress`, `{ id, state, current?, next, spoken, replies, limit, ended?, queued? }`, with `id` the group message that opened the round, `state` `running` or `ended`, `current` the member speaking, `next` those still to speak in order (a handoff appended), `spoken` those who replied, `ended` (once it ended) `done`, `limit` or `stopped`, and `queued` the owner's messages waiting for the next round. Called when the round is planned, before and after each member's turn, and when it ends; each call carries the whole state. Called for the room keys of a surface that hosts rooms, never for a Discord channel; a rejection is logged and the round goes on | the surface shows no rounds |
3044
3073
 
@@ -3049,7 +3078,7 @@ A room is a conversation of its own, apart from the agent's or group's Discord c
3049
3078
  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.
3050
3079
  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.
3051
3080
  A schedule an agent sets from a room belongs to the room, and the agent's own `schedule_list`, `schedule_update` and `schedule_cancel` reach it from every other conversation of that agent (its Discord channel and each room, `AGENTS.team.roomsOf(name)`); `schedule_list` says where each one fires. Only the agent's own schedules: another agent's are still read by name with `agent`, never changed.
3052
- `message_agent` delivers into the target's most recent conversation, the one it last ran a turn in (kept in the table `agent_latest_conversation`, so a restart keeps it), whether that is its Discord channel or a room. An agent that has run no turn yet is reached on the sender's surface: a room's message stays among the rooms. The optional `conversation` chooses another: `"discord"`, `"web"` (the one surface that hosts rooms, or that surface's name), or one of the conversation keys `agent_get` lists. The answer, and the follow-up turn it wakes, return to the conversation the sender wrote from, wherever the target answered. When the surface of the sender hosts [dispatch threads](#dispatch-threads-on-any-surface) the exchange opens its thread there, as it does in a Discord channel, and the request and the answer go there under each agent's name.
3081
+ `message_agent` delivers into the target's most recent conversation, the one it last ran a turn in (kept in the table `agent_latest_conversation`, so a restart keeps it), whether that is its Discord channel or a room. An agent that has run no turn yet is reached on the sender's surface: a room's message stays among the rooms. The optional `conversation` chooses another: `"discord"`, `"web"` (the one surface that hosts rooms, or that surface's name), or one of the conversation keys `agent_get` lists. The answer, and the follow-up turn it wakes, return to the conversation the sender wrote from, wherever the target answered. The sender's conversation shows the exchange as cards, through `sendReply` with `OutboundReply.intercom` (an `IntercomCard`: `kind` `sent`, `answered` or `failed`, the agent the request went to as `peer`, and the whole `text`): the request under the sender's name when it is sent, then the answer, or why none came, under the target's. A surface that shows cards draws a small card that opens to the text; `chunks` carry the card's heading and the text for one that does not. An empty answer posts no card, and no thread is opened.
3053
3082
  Starting a room over resets that room's session (or a group room's seats) alone.
3054
3083
  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.
3055
3084
 
@@ -3072,7 +3101,7 @@ An agent has a *place* on each surface that holds one: its Discord channel, and
3072
3101
  | `primary(agent)` | The primary place: the surface the rules name, and with `auto` the agent's Discord channel when it has one, else its room on the first surface that hosts rooms. Undefined for an unknown agent. |
3073
3102
  | `placeOn(agent, surface)` | The agent's place on a surface (`web` for the one surface that hosts rooms); undefined when it has none there. |
3074
3103
  | `of(agent, "integration", origin)` | Where the report turn of a foreign webhook's post made in `origin` runs; `origin` itself by default, and also when the destination has vanished. |
3075
- | `of(agent, "agentMessage", from)` | The conversation a `message_agent` call that names none reaches: by default the agent's latest, never out of a room into Discord. Throws `AgentError` when the agent has no place. |
3104
+ | `of(agent, "agentMessage", from)` | The conversation a `message_agent` call that names none reaches: by default (`origin-surface`) the agent's place on the surface `from` is on, else its primary place, logged; it never crosses surfaces by recency. Throws `AgentError` when the agent has no place. |
3076
3105
  | `of(agent, "opening", origin?)` | Where an opening task runs: by default the agent's place on the surface `origin` is on, else its primary place; undefined when it has no place at all. |
3077
3106
  | `place("ops")` | A named place of the host: the conversation, or the primary place of the agent it names while that agent is active. |
3078
3107
 
@@ -3090,17 +3119,17 @@ Both tools are on the `member` tier and are offered to every agent that is not i
3090
3119
  A temp knight is a real agent: an ordinary row of the agent table with a nullable `parent` and `finished_at` (the migration `agents-helpers`, additive), its own prompt, picture and sessions, and a name that defaults to `<parent>-<n>`.
3091
3120
  Its model and thinking level are its knight's unless the call names others.
3092
3121
  It has no group seat, and no agent, group, archive, channel-arrange or schedule tools.
3093
- It follows its knight's routing rules, and whether it counts as a long-running conversation follows the conversation that made it.
3122
+ It follows its knight's routing rules, and its yolo and long-running modes follow the conversation that made it, live (see [modes a temp knight inherits](#modes-a-temp-knight-inherits)).
3094
3123
 
3095
3124
  | Rule | What happens |
3096
3125
  |---|---|
3097
- | Place | On Discord a temp knight lives in a thread under its knight's channel, whose posts go through the channel's webhook under the temp knight's own name and picture; a knight with no Discord channel gives it a room on a surface that hosts rooms. Archiving a temp knight archives and locks its thread, and restoring it unarchives the thread. |
3126
+ | Place | A temp knight lives on the surface of the conversation that made it, kept in `Agent.origin`; its place on the other surface is made only when someone uses it there. Made on Discord, it lives in a thread under its knight's channel, whose posts go through the channel's webhook under the temp knight's own name and picture; a surface's room list leaves its room out until the room has a message or someone opens it by its address. Made in a room, it gets no thread: the thread is made, once, when a `message_agent` call from a turn that may message it names `conversation: "discord"`, or names none from a Discord conversation, where an unnamed message goes. A knight with no Discord channel gives it a room on a surface that hosts rooms. Archiving a temp knight archives and locks its thread when it has one; restoring it unarchives the thread, and only one made on Discord gets a new thread when the old one is gone. A temp knight made before its origin was kept is placed as one made on Discord. |
3098
3127
  | Finished | Derived, never set by the knight: nothing runs, nothing is queued, no card waits for the owner and no message from its knight is still unanswered. `Agent.finishedAt` carries the stamp. |
3099
3128
  | Limits | `helpers.perParent` (default 4) and `helpers.perHost` (default 12) count active temp knights; depth is one, so a temp knight cannot make one. At a limit the knight's oldest finished temp knight is archived to make room, and the call is refused only when every one of them is busy. |
3100
3129
  | Cleanup | `helper_end`, the owner's clearing in a surface, a knight's own archive (which archives its temp knights first), and, after `helpers.archiveFinishedAfterHours` (default 24), an hourly sweep. An archived temp knight keeps its history and comes back with `restoreHelpers`. Deleting an archived knight deletes its temp knights. |
3101
3130
 
3102
3131
  `AgentTeam` offers `helpersOf(parent)`, `finishedHelpers(parents?)`, `archiveHelpers(names, by)`, `restoreHelpers(names)` (only a name that is still an archived temp knight of an active knight is restored) and `onHelpersEnded(listener)`, which hears a knight's own `helper_end` and nothing else, so a surface can offer the owner an undo.
3103
- `isHelper(agent)` tells a temp knight, for a surface that lists agents; a group never takes one.
3132
+ `isHelper(agent)` tells a temp knight, for a surface that lists agents, and `helperSurface(agent)` the surface it was made on (undefined for one made before its origin was kept); a group never takes one.
3104
3133
  The `helpers` block of the configuration turns the limits and the sweep; `helpers: false` offers neither tool, and a value out of its range, or a `perParent` above `perHost`, stops the start with a `ConfigError`:
3105
3134
 
3106
3135
  ```ts
@@ -3111,7 +3140,7 @@ defineRoundtable({
3111
3140
 
3112
3141
  #### Dispatch threads on any surface
3113
3142
 
3114
- 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.
3143
+ A background dispatch (a coding task, a delegated task) 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.
3115
3144
  A surface hosts them with `threads`, a `SurfaceThreads`:
3116
3145
 
3117
3146
  | Method | What the surface does |
@@ -3192,12 +3221,12 @@ Because anyone who can see the card can copy the text, it holds nothing they may
3192
3221
  #### A prompt that outlives its turn: `PromptWait`
3193
3222
 
3194
3223
  `confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy?, prompt? }`, `approvedBy` naming the approver where the surface knows them and `prompt` the id of the card that was answered, `{ kind: "question", answer, by }`, or `{ kind: "questionnaire", answer: QuestionnaireAnswer, by }`, `by` naming who answered; only a `questionnaire` call can produce the last) and returns the text of the turn that tells the conversation, or undefined for none.
3195
- Given one, a surface may stop the turn's wait after a grace period: `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
3224
+ Given one, a surface may stop the turn's wait after a grace period (`wait.graceMs` when the turn gives one, as a yolo turn's `ask_user` does, else the surface's own): `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
3196
3225
  Without one, a prompt waits for its answer, or for its turn's stop; a surface that cannot start a turn ignores `wait` and waits too.
3197
3226
  A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
3198
3227
 
3199
3228
  The runtime gives both of its prompts a `wait`.
3200
- `ask_user` then tells the model the person has not answered yet and to end its turn saying it is waiting, without asking again, and the late answer's turn carries the question and the answer.
3229
+ `ask_user` then tells the model the person has not answered yet and to end its turn saying it is waiting, without asking again (in a yolo conversation, to keep working instead; see [cards in a yolo conversation](#cards-in-a-yolo-conversation)), and the late answer's turn carries the question and the answer.
3201
3230
  A held action's call is blocked as awaiting the approval, and is not held for a later message; a late approval lets that exact call (same tool, same canonical input) run once, and its turn tells the model to make it now and report the result, so the call runs under the gate and tier rules every call does. The release is the approval of one call, so it is spent only by that call: in a turn whose speaker is the person who approved on the card (or an owner; a surface that does not say who approved leaves it to any speaker), and only when that person's message started the turn (`TurnRequest.byMessage`, set by the core's message paths: an owner's message in an agent channel, a group round, a web chat or remote MCP message). A schedule's, agent-message (follow-ups included), report, webhook or error-report turn never spends it, whoever its speaker is and whatever their tier, and the system principal never does; they show the card again, within an hour of the answer (`LATE_RELEASE_TTL_MS`), and not after the identical call has run without a hold, which is the run the person approved, so it is dropped then. A release nobody spends in that time is gone, and the same call is held again for a card; a late refusal starts a turn that says so.
3202
3231
  A coding worker's cards give no `wait`, since nothing could resume a worker: it waits for the answer, and the waiting does not count against its time.
3203
3232
 
@@ -3231,7 +3260,7 @@ A model often writes text in an assistant message that then calls tools, such as
3231
3260
  On a surface that gives `interim`, and in the agent server's channels through the agents' webhooks, the host posts that text as the turn goes, sorted in two:
3232
3261
 
3233
3262
  - **Primary** text is posted as ordinary messages as soon as its assistant message ends: text of 400 characters or more, or written with a Markdown heading, list, table or code fence.
3234
- - **Secondary** text, short narration between tool calls, goes to one progress message per run of tool-calling messages, in Discord's small text (`-# ` per line): each text as a line, then the tools called in the run, such as `-# bash ×3 · read · web_search` (names only). It is edited in place at most every 1.5 seconds, its last state always lands, and it stays inside 2000 characters by dropping its oldest lines behind `-# …`. A primary post or a card starts a new progress message.
3263
+ - **Secondary** text, short narration between tool calls, goes to one progress message per run of tool-calling messages, in Discord's small text (`-# ` per line): each text as a line, then the tools called in the run, such as `-# bash ×3 · read · web_search` (names only). It is edited in place at most every 1.5 seconds, its last state always lands, and it stays inside 2000 characters by dropping its oldest lines behind `-# …`. A primary post or a card starts a new progress message. Posts that set `InterimPosts.tools` to `false`, as the web app's rooms do because their progress pane shows the tools, get the narration without the tool line, and a run that wrote no narration posts nothing; Discord keeps the line.
3235
3264
 
3236
3265
  Before any card, an `ask_user` question or an approval, the host posts the pending text and brings the progress message up to date, so what the model wrote before the card shows above it.
3237
3266
  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.
@@ -3740,7 +3769,7 @@ The parts are medieval: the clothes, hats and gear of an agent are what a late-m
3740
3769
 
3741
3770
  ### The picture: `composeSvg`
3742
3771
 
3743
- `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`, `label` and `skins` (six `#rrggbb` skin tones that `CharacterSpec.skin` indexes, in place of `CATALOG.skins`; anything but six valid colors throws a `CharacterError` whose `field` is `skins` or `skins[3]`).
3772
+ `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), `mouth` (`open` or `closed`, drawn in place of the expression's own mouth, for the two frames of a talking figure), `still`, `uid`, `size`, `label` and `skins` (six `#rrggbb` skin tones that `CharacterSpec.skin` indexes, in place of `CATALOG.skins`; anything but six valid colors throws a `CharacterError` whose `field` is `skins` or `skins[3]`).
3744
3773
 
3745
3774
  - 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`.
3746
3775
  - `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`.
@@ -4137,7 +4166,7 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
4137
4166
  Every call shares the same shutdown.
4138
4167
  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.
4139
4168
  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.
4140
- 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.
4169
+ 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; in a yolo conversation the card waits `autonomy.yolo.timeoutCardSeconds` instead and the turn goes on (see [cards in a yolo conversation](#cards-in-a-yolo-conversation)). 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.
4141
4170
  A turn that cannot show a card (a schedule's turn, a member's turn, an agent's message, an agent's opening task, a turn without a surface) is aborted at once, as before, unless the conversation is [long-running](#long-running-conversations) or, for a schedule, the schedule has its own `time_limit_minutes`. The failure it posts in the conversation names the limit and, for such a turn, says that no one was in the conversation to ask for more time and how to allow it (`TurnTimeout.unasked`), so the owner sees where it stopped. 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.
4142
4171
  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):
4143
4172
 
@@ -4153,7 +4182,7 @@ A turn whose model ignores its abort (a subprocess that does not exit) is given
4153
4182
 
4154
4183
  ### Long-running conversations
4155
4184
 
4156
- 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. `agent_create` takes `long_running: true` to switch the new agent's conversations to this mode before its opening task starts; only an owner's turn may (a lower tier, or the host's own turn, is refused before anything is made), and a host with no `modes` service refuses it.
4185
+ 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 (yolo mode lets an unanswered `ask_user` card go on without its answer; see [cards in a yolo conversation](#cards-in-a-yolo-conversation)). `agent_create` takes `long_running: true` to switch the new agent's conversations to this mode before its opening task starts; only an owner's turn may (a lower tier, or the host's own turn, is refused before anything is made), and a host with no `modes` service refuses it.
4157
4186
  In a turn nobody is present in (a schedule's, an agent's message, an opening task) `ask_user` has no card to show: it refuses at once, telling the model to write the question as its final answer, which is posted to the agent's room, and the answer arrives as a new turn.
4158
4187
 
4159
4188
  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.
@@ -4388,6 +4417,7 @@ Import from the entries listed below; source area files are internal.
4388
4417
  | `AvatarStudio` | `pi-roundtable` | type |
4389
4418
  | `PROMPTS` | `pi-roundtable` | value |
4390
4419
  | `characterOf` | `pi-roundtable` | value |
4420
+ | `helperSurface` | `pi-roundtable` | value |
4391
4421
  | `isHelper` | `pi-roundtable` | value |
4392
4422
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
4393
4423
  | `CONVERSATIONS` | `pi-roundtable` | value |
@@ -4461,6 +4491,7 @@ Import from the entries listed below; source area files are internal.
4461
4491
  | `IdentityService` | `pi-roundtable` | type |
4462
4492
  | `ImageDrawer` | `pi-roundtable` | type |
4463
4493
  | `InboundMessage` | `pi-roundtable` | type |
4494
+ | `IntercomCard` | `pi-roundtable` | type |
4464
4495
  | `InterimMessage` | `pi-roundtable` | type |
4465
4496
  | `InterimPosts` | `pi-roundtable` | type |
4466
4497
  | `InterimTextMode` | `pi-roundtable` | type |
@@ -4578,6 +4609,7 @@ Import from the entries listed below; source area files are internal.
4578
4609
  | `PromptConflictError` | `pi-roundtable` | value |
4579
4610
  | `SkillConflictError` | `pi-roundtable` | value |
4580
4611
  | `SkillDocument` | `pi-roundtable` | type |
4612
+ | `SkillGroup` | `pi-roundtable` | type |
4581
4613
  | `SKILLS` | `pi-roundtable` | value |
4582
4614
  | `SINGLE_QUESTION` | `pi-roundtable` | value |
4583
4615
  | `Schedule` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.27.1",
3
+ "version": "0.29.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,11 @@ export type CharacterLook =
30
30
  export interface ComposeOptions {
31
31
  /** Default `STATE_EXPRESSION[state ?? "idle"]`. */
32
32
  expr?: Expression;
33
+ /**
34
+ * The mouth drawn open or closed in place of the expression's own, for the frames of a talking
35
+ * figure; the expression's own mouth when absent.
36
+ */
37
+ mouth?: "open" | "closed";
33
38
  /** With `still`: which overlay is baked in. Live output carries every overlay. */
34
39
  state?: AvatarState;
35
40
  /** Background and ring only. Default `"modern-light"`. */
@@ -55,6 +60,7 @@ export interface ComposeOptions {
55
60
 
56
61
  const LOOKS = ["modern-light", "modern-dark", "camelot-light", "camelot-dark"];
57
62
  const FRAMES = ["disc", "shield"] as const;
63
+ const MOUTHS = ["open", "closed"] as const;
58
64
  const UID = /^[a-z0-9]{1,16}$/;
59
65
  const MAX_LABEL = 200;
60
66
  const MAX_SIZE = 4096;
@@ -174,6 +180,10 @@ export function composeSvg(
174
180
  "look",
175
181
  ) as CharacterLook;
176
182
  const frame = checkOneOf(FRAMES, options.frame ?? "disc", "frame");
183
+ const mouth =
184
+ options.mouth === undefined
185
+ ? undefined
186
+ : checkOneOf(MOUTHS, options.mouth, "mouth");
177
187
  const uid = options.uid ?? "a";
178
188
  if (typeof uid !== "string" || !UID.test(uid))
179
189
  throw new CharacterError("uid", "must match /^[a-z0-9]{1,16}$/");
@@ -203,6 +213,7 @@ export function composeSvg(
203
213
  const face = faceParts(
204
214
  { ink: INK, brow: mix(s.hairColor, INK, 0.5), dot: mix(tones.p, INK, 0.1) },
205
215
  expr,
216
+ mouth,
206
217
  );
207
218
 
208
219
  const hair = HAIR[s.hair](tones);
@@ -22,6 +22,22 @@ type MouthShape =
22
22
  | "tongue"
23
23
  | "yawn"
24
24
  | "tilt";
25
+ /** The mouths that show an open mouth; the rest are a closed line. */
26
+ const OPEN_MOUTHS: ReadonlySet<MouthShape> = new Set([
27
+ "grin",
28
+ "o",
29
+ "tongue",
30
+ "yawn",
31
+ "tilt",
32
+ ]);
33
+
34
+ /** A talking figure's mouth for one frame: the expression's own when it already is open or closed, else an open "o" or a closed smile. */
35
+ function talking(shape: MouthShape, mouth: "open" | "closed"): MouthShape {
36
+ const open = OPEN_MOUTHS.has(shape);
37
+ if (mouth === "open") return open ? shape : "o";
38
+ return open ? "smile" : shape;
39
+ }
40
+
25
41
  type Extra = "blush" | "sweat" | "tear" | "sparkle" | "dots" | "burst";
26
42
 
27
43
  interface ExpressionRow {
@@ -263,9 +279,14 @@ const star = (x: number, y: number, r: number, color: string): string =>
263
279
  const drop = (x: number, y: number, s: number, color: string): string =>
264
280
  `<path d="M${x} ${y - 3.6 * s}Q${x + 3 * s} ${y + 0.2 * s} ${x} ${y + 2.8 * s}Q${x - 3 * s} ${y + 0.2 * s} ${x} ${y - 3.6 * s}Z" fill="${color}"/>`;
265
281
 
266
- export function faceParts(colors: FaceColors, expression: Expression): Face {
282
+ export function faceParts(
283
+ colors: FaceColors,
284
+ expression: Expression,
285
+ talk?: "open" | "closed",
286
+ ): Face {
267
287
  const c = FACE;
268
288
  const row = EXPRESSION_ROWS[expression];
289
+ const shape = talk === undefined ? row.mouth : talking(row.mouth, talk);
269
290
  const [lx, ly] = row.look;
270
291
  const eye = (cx: number, side: 0 | 1) => {
271
292
  const big = row.big && !side;
@@ -279,7 +300,7 @@ export function faceParts(colors: FaceColors, expression: Expression): Face {
279
300
  ) =>
280
301
  `<path d="M${-c.bl / 2} 0H${c.bl / 2}" transform="translate(${cx} ${f1(c.ey + c.bdy + dy)}) rotate(${side ? -tilt : tilt})" stroke="${colors.brow}" stroke-width="${c.bw}" stroke-linecap="round" fill="none"/>`;
281
302
  const brows = `<g class="brows">${brow(c.ex[0], row.bl, 0)}${brow(c.ex[1], row.br, 1)}</g>`;
282
- const mouth = `<g class="mouth" transform="translate(${c.mx} ${c.my}) scale(${c.ms})">${mouthShape(row.mouth, colors.ink)}</g>`;
303
+ const mouth = `<g class="mouth" transform="translate(${c.mx} ${c.my}) scale(${c.ms})">${mouthShape(shape, colors.ink)}</g>`;
283
304
  const [fx, fy] = c.fx;
284
305
  const blush = (cx: number) =>
285
306
  `<ellipse cx="${cx}" cy="${c.ey + c.by}" rx="${c.br}" ry="${f1(c.br * 0.62)}" fill="#ff7a8e" opacity=".5"/>`;