pi-roundtable 0.27.1 → 0.28.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 (49) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +29 -13
  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 +32 -85
  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-tools.ts +1 -1
  12. package/src/core/agents/helper-create.ts +3 -24
  13. package/src/core/agents/helper-modes.ts +64 -0
  14. package/src/core/agents/team-turn-types.ts +7 -13
  15. package/src/core/agents/team-turns.ts +7 -2
  16. package/src/core/builtin/agent-server.ts +3 -4
  17. package/src/core/builtin/discord.ts +35 -8
  18. package/src/core/config/adapters.ts +2 -1
  19. package/src/core/config/limits.ts +35 -2
  20. package/src/core/discord/agent-discord.ts +12 -1
  21. package/src/core/discord/card-copies.ts +7 -2
  22. package/src/core/discord/discord-surface.ts +13 -30
  23. package/src/core/discord/intercom-cards.ts +121 -0
  24. package/src/core/discord/owner-card-asks.ts +9 -0
  25. package/src/core/discord/owner-card-types.ts +2 -0
  26. package/src/core/discord/owner-cards.ts +2 -1
  27. package/src/core/discord/stop-button.ts +35 -0
  28. package/src/core/domain/conversation.ts +18 -0
  29. package/src/core/domain/interim.ts +7 -0
  30. package/src/core/domain/turn-timeout.ts +5 -0
  31. package/src/core/i18n/conversation.ts +16 -4
  32. package/src/core/i18n/turn-timeout.ts +10 -2
  33. package/src/core/interactions/prompts.ts +5 -0
  34. package/src/core/modes/autonomy.ts +22 -1
  35. package/src/core/modes/conversation-modes.ts +135 -23
  36. package/src/core/modes/mode-inheritance.ts +78 -0
  37. package/src/core/modes/modes-plugin.ts +1 -1
  38. package/src/core/modules/skills/skill-contract.ts +2 -0
  39. package/src/core/presentation/intercom.ts +44 -0
  40. package/src/core/runtime/ask-questions.ts +21 -4
  41. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  42. package/src/core/runtime/extensions/ask-user.ts +21 -1
  43. package/src/core/runtime/interim-text.ts +7 -4
  44. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  45. package/src/core/runtime/prompt-slot.ts +11 -1
  46. package/src/core/runtime/session-factory.ts +11 -6
  47. package/src/core/runtime/turn-budget.ts +34 -3
  48. package/src/core/runtime/turn-limits.ts +13 -3
  49. package/src/index.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.28.0] - 2026-10-31
9
+
10
+ ### Added
11
+
12
+ - 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.
13
+ - 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.
14
+ - 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`).
15
+ - Discord keeps intercom cards' full texts in `intercom-texts.json` (newest 300, following `discord.cardCopies`), opened ephemerally with **Show all**.
16
+ - The character kit's `composeSvg` takes `mouth: "open" | "closed"` for the frames of a talking figure.
17
+ - `SkillRegistry.delete(name)` is part of the skill registry interface, so a host surface can remove an agent-written skill as `skill_delete` does.
18
+
19
+ ### Changed
20
+
21
+ - `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.
22
+ - `InterimPosts.tools: false` leaves the tool line out of the progress message; the web app's rooms set it, Discord keeps the line.
23
+
24
+ ### Fixed
25
+
26
+ - `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".
27
+
8
28
  ## [0.27.1] - 2026-10-31
9
29
 
10
30
  ### 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
@@ -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
@@ -3027,7 +3042,7 @@ The agent server claims `discord:` keys, and, only on a surface that declares `a
3027
3042
  |---|---|---|
3028
3043
  | `surface` | The key prefix | (required) |
3029
3044
  | `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) |
3045
+ | `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
3046
  | `stop()` | Disconnects when the host stops | nothing to stop |
3032
3047
  | `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
3048
  | `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 +3053,7 @@ The agent server claims `discord:` keys, and, only on a surface that declares `a
3038
3053
  | `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
3054
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
3040
3055
  | `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 |
3056
+ | `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
3057
  | `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
3058
  | `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
3059
 
@@ -3049,7 +3064,7 @@ A room is a conversation of its own, apart from the agent's or group's Discord c
3049
3064
  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
3065
  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
3066
  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.
3067
+ `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
3068
  Starting a room over resets that room's session (or a group room's seats) alone.
3054
3069
  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
3070
 
@@ -3090,7 +3105,7 @@ Both tools are on the `member` tier and are offered to every agent that is not i
3090
3105
  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
3106
  Its model and thinking level are its knight's unless the call names others.
3092
3107
  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.
3108
+ 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
3109
 
3095
3110
  | Rule | What happens |
3096
3111
  |---|---|
@@ -3111,7 +3126,7 @@ defineRoundtable({
3111
3126
 
3112
3127
  #### Dispatch threads on any surface
3113
3128
 
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.
3129
+ 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
3130
  A surface hosts them with `threads`, a `SurfaceThreads`:
3116
3131
 
3117
3132
  | Method | What the surface does |
@@ -3192,12 +3207,12 @@ Because anyone who can see the card can copy the text, it holds nothing they may
3192
3207
  #### A prompt that outlives its turn: `PromptWait`
3193
3208
 
3194
3209
  `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.
3210
+ 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
3211
  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
3212
  A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
3198
3213
 
3199
3214
  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.
3215
+ `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
3216
  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
3217
  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
3218
 
@@ -3231,7 +3246,7 @@ A model often writes text in an assistant message that then calls tools, such as
3231
3246
  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
3247
 
3233
3248
  - **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.
3249
+ - **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
3250
 
3236
3251
  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
3252
  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 +3755,7 @@ The parts are medieval: the clothes, hats and gear of an agent are what a late-m
3740
3755
 
3741
3756
  ### The picture: `composeSvg`
3742
3757
 
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]`).
3758
+ `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
3759
 
3745
3760
  - 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
3761
  - `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 +4152,7 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
4137
4152
  Every call shares the same shutdown.
4138
4153
  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
4154
  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.
4155
+ 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
4156
  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
4157
  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
4158
 
@@ -4153,7 +4168,7 @@ A turn whose model ignores its abort (a subprocess that does not exit) is given
4153
4168
 
4154
4169
  ### Long-running conversations
4155
4170
 
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.
4171
+ 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
4172
  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
4173
 
4159
4174
  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.
@@ -4461,6 +4476,7 @@ Import from the entries listed below; source area files are internal.
4461
4476
  | `IdentityService` | `pi-roundtable` | type |
4462
4477
  | `ImageDrawer` | `pi-roundtable` | type |
4463
4478
  | `InboundMessage` | `pi-roundtable` | type |
4479
+ | `IntercomCard` | `pi-roundtable` | type |
4464
4480
  | `InterimMessage` | `pi-roundtable` | type |
4465
4481
  | `InterimPosts` | `pi-roundtable` | type |
4466
4482
  | `InterimTextMode` | `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.28.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"/>`;
@@ -1,9 +1,9 @@
1
- import type { ChannelKey } from "../domain/conversation.ts";
1
+ import type { ChannelKey, IntercomCard } from "../domain/conversation.ts";
2
2
  import { AgentError, IdentityError } from "../domain/errors.ts";
3
3
  import type { AgentTurnScope } from "../domain/ports.ts";
4
4
  import { HostStoppingError } from "../errors.ts";
5
- import { messages } from "../i18n/index.ts";
6
5
  import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
6
+ import { intercomPlain } from "../presentation/intercom.ts";
7
7
  import { isQuiet } from "../presentation/reply-body.ts";
8
8
  import { splitReply } from "../presentation/reply-splitter.ts";
9
9
  import { addressee, type Speaker } from "../speakers.ts";
@@ -14,14 +14,12 @@ import {
14
14
  agentReplyText,
15
15
  chainLimitReason,
16
16
  deliveredMessagePost,
17
- returnedAnswerPost,
18
17
  } from "./agent-prompt.ts";
19
18
  import type { Agent } from "./agent-store.ts";
20
19
  import { conversationLabel, messageTarget } from "./message-target.ts";
21
20
  import { discordIdOf, discordKey, scopeOf } from "./team-keys.ts";
22
21
  import {
23
22
  type Chain,
24
- type Exchange,
25
23
  MAX_CHAIN_MESSAGES,
26
24
  type TurnHost,
27
25
  } from "./team-turn-types.ts";
@@ -90,25 +88,18 @@ export class AgentMessages {
90
88
  chain.messages += 1;
91
89
  // A temp knight is working from the moment a message is handed to it until it has answered.
92
90
  this.#host.helpers.begin(target.name);
93
- // The thread opens in the sender's turn channel, on its surface when that hosts threads; a
94
- // surface that hosts none leaves the answer returning to the sender's conversation.
95
- const exchange = this.#openExchange(parent, sender, target, text);
91
+ // The request shows in the sender's turn channel as a small card under the sender's name.
92
+ this.#card(parent, sender, {
93
+ kind: "sent",
94
+ peer: this.#peer(target),
95
+ text,
96
+ });
96
97
  void queue
97
98
  .run(targetChannel, async () =>
98
- this.#deliver(
99
- sender,
100
- target,
101
- text,
102
- chain,
103
- await exchange,
104
- targetChannel,
105
- parent,
106
- ),
99
+ this.#exchange(sender, target, text, chain, targetChannel, parent),
107
100
  )
108
101
  .catch((error: unknown) => {
109
102
  if (error instanceof HostStoppingError) {
110
- // Refused before it started: the thread it opened has nothing more to hold.
111
- void exchange.then((opened) => opened?.thread.close());
112
103
  this.#keep({
113
104
  kind: "message",
114
105
  sender: sender.name,
@@ -139,69 +130,36 @@ export class AgentMessages {
139
130
  return `Sent to "${target.displayName}" in its ${conversationLabel(targetChannel)}. It works there; its answer comes back to you as a new turn.`;
140
131
  }
141
132
 
133
+ /** The other agent of a card, as it is named now. */
134
+ #peer(agent: Agent): IntercomCard["peer"] {
135
+ const current = this.#host.current(agent);
136
+ return { name: current.name, displayName: current.displayName };
137
+ }
138
+
142
139
  /**
143
- * Opens the message's thread in the sender's turn channel, a Discord channel or a room, and
144
- * posts the request there under the sender's name; undefined where no thread opens. Never
145
- * rejects.
140
+ * Posts a card of the exchange in the channel under `as`'s name: the card where the channel's
141
+ * surface shows cards, and its heading and text where it shows none. Never rejects.
146
142
  */
147
- async #openExchange(
148
- parent: ChannelKey,
149
- sender: Agent,
150
- target: Agent,
151
- text: string,
152
- ): Promise<Exchange | undefined> {
153
- const thread = await this.#host.options.threads?.open(
154
- parent,
155
- `→ ${target.displayName}`,
156
- );
157
- if (!thread) return undefined;
158
- await this.#host
159
- .post(parent, this.#host.current(sender), {
160
- chunks: splitReply(
161
- deliveredMessagePost(this.#host.current(target), text),
162
- ),
163
- threadId: thread.id,
143
+ #card(channel: ChannelKey, as: Agent, card: IntercomCard): Promise<void> {
144
+ return this.#host
145
+ .post(channel, this.#host.current(as), {
146
+ chunks: splitReply(intercomPlain(card)),
147
+ intercom: card,
164
148
  })
165
149
  .catch((error: unknown) =>
166
150
  this.#host.options.logger.warn(
167
- { err: error },
168
- "agent request not threaded",
151
+ { channel, kind: card.kind, err: error },
152
+ "agent message card not posted",
169
153
  ),
170
154
  );
171
- return { parent, thread };
172
- }
173
-
174
- /** Runs the target's turn, then posts its answer and wakes the sender; closes the thread either way. */
175
- async #deliver(
176
- sender: Agent,
177
- target: Agent,
178
- text: string,
179
- chain: Chain,
180
- exchange: Exchange | undefined,
181
- targetChannel: ChannelKey,
182
- senderChannel: ChannelKey,
183
- ): Promise<void> {
184
- try {
185
- await this.#exchange(
186
- sender,
187
- target,
188
- text,
189
- chain,
190
- exchange,
191
- targetChannel,
192
- senderChannel,
193
- );
194
- } finally {
195
- await exchange?.thread.close();
196
- }
197
155
  }
198
156
 
157
+ /** Runs the target's turn, then shows its answer in the sender's channel and wakes the sender. */
199
158
  async #exchange(
200
159
  sender: Agent,
201
160
  target: Agent,
202
161
  text: string,
203
162
  chain: Chain,
204
- exchange: Exchange | undefined,
205
163
  targetChannel: ChannelKey,
206
164
  senderChannel: ChannelKey,
207
165
  ): Promise<void> {
@@ -223,9 +181,6 @@ export class AgentMessages {
223
181
  chain,
224
182
  { unattended: true },
225
183
  );
226
- const thread = exchange?.thread;
227
- if (thread && !result.ok)
228
- await thread.close(messages().agentNoReply(result.error.message));
229
184
  // A target with nothing to answer sends nothing back and wakes nobody.
230
185
  if (isQuiet(result)) {
231
186
  logger.info(
@@ -236,29 +191,22 @@ export class AgentMessages {
236
191
  }
237
192
  const current = this.#host.options.store.agent(sender.name);
238
193
  if (current?.status !== "active") return;
239
- // With a thread the answer goes there, leaving the sender's channel its own reply.
240
- if (result.ok)
241
- await this.#host
242
- .post(senderChannel, this.#host.current(target), {
243
- chunks: splitReply(returnedAnswerPost(result.text)),
244
- ...(thread ? { threadId: thread.id } : {}),
245
- })
246
- .catch((error: unknown) =>
247
- logger.error({ err: error }, "agent answer not posted back"),
248
- );
249
- await thread?.close();
194
+ // The answer, or why none came, shows in the sender's channel as a small card.
195
+ await this.#card(senderChannel, target, {
196
+ kind: result.ok ? "answered" : "failed",
197
+ peer: this.#peer(target),
198
+ text: result.ok ? result.text : result.error.message,
199
+ });
250
200
  const followUp = result.ok
251
201
  ? agentReplyText(
252
202
  addressee(chain.speaker, this.#host.options.owner),
253
203
  this.#host.current(target),
254
204
  result.text,
255
- thread?.mention,
256
205
  )
257
206
  : agentFailureText(
258
207
  addressee(chain.speaker, this.#host.options.owner),
259
208
  this.#host.current(target),
260
209
  result.error.message,
261
- thread?.mention,
262
210
  );
263
211
  // The answer is a report back to the sender, so its turn may ask the owner on cards.
264
212
  void queue
@@ -407,7 +355,7 @@ export class AgentMessages {
407
355
  await this.#followUp(sender, entry.channel, entry.text, chain);
408
356
  return;
409
357
  }
410
- // Its thread was closed when the host refused it, so the answer returns to the sender's conversation.
358
+ // Its request card was posted when it was sent; the answer's card follows as it would have.
411
359
  this.#host.helpers.begin(target.name);
412
360
  try {
413
361
  await this.#exchange(
@@ -415,7 +363,6 @@ export class AgentMessages {
415
363
  target,
416
364
  entry.text,
417
365
  chain,
418
- undefined,
419
366
  entry.channel,
420
367
  origin,
421
368
  );
@@ -1,4 +1,5 @@
1
1
  import type { AgentRuntime, ContextUse } from "../contract/runtime.ts";
2
+ import type { IntercomCard } from "../domain/conversation.ts";
2
3
  import type { InterimPosts } from "../domain/interim.ts";
3
4
  import type { ThinkingSetting } from "../models.ts";
4
5
 
@@ -24,6 +25,8 @@ export interface AgentPost {
24
25
  files?: { name: string; data: Uint8Array }[];
25
26
  /** A thread of the channel to post in instead, such as a dispatch's. */
26
27
  threadId?: string;
28
+ /** A message between agents, shown as a small card in place of `chunks` where the channel can. */
29
+ intercom?: IntercomCard;
27
30
  }
28
31
 
29
32
  /**
@@ -134,16 +134,8 @@ export function agentReplyText(
134
134
  who: OwnerIdentity,
135
135
  from: Agent,
136
136
  text: string,
137
- thread?: string,
138
137
  ): string {
139
- return `(The agent "${from.displayName}" (\`${from.name}\`) answered your message. Continue with it and tell ${who.name} what matters; your reply is posted in your channel.${threadNote(thread)})\n\n${text}`;
140
- }
141
-
142
- /** Where the exchange was posted, when it had a thread. */
143
- function threadNote(thread: string | undefined): string {
144
- return thread
145
- ? ` The exchange is in the thread ${thread}, now archived; link it when useful.`
146
- : "";
138
+ return `(The agent "${from.displayName}" (\`${from.name}\`) answered your message. Continue with it and tell ${who.name} what matters; your reply is posted in your channel.)\n\n${text}`;
147
139
  }
148
140
 
149
141
  /** What the sender receives when the target could not answer. */
@@ -151,9 +143,8 @@ export function agentFailureText(
151
143
  who: OwnerIdentity,
152
144
  from: Agent,
153
145
  reason: string,
154
- thread?: string,
155
146
  ): string {
156
- return `(The agent "${from.displayName}" (\`${from.name}\`) could not answer your message: ${reason}. Tell ${who.name} briefly, or try again later.${threadNote(thread)})`;
147
+ return `(The agent "${from.displayName}" (\`${from.name}\`) could not answer your message: ${reason}. Tell ${who.name} briefly, or try again later.)`;
157
148
  }
158
149
 
159
150
  /** A new agent's first turn. */
@@ -192,11 +183,6 @@ export function deliveredMessagePost(to: Agent, text: string): string {
192
183
  return messages().messageDelivered(to.displayName, text);
193
184
  }
194
185
 
195
- /** How the target's answer appears in the sender's channel. */
196
- export function returnedAnswerPost(text: string): string {
197
- return messages().answerReturned(text);
198
- }
199
-
200
186
  /** Why a chain of agent messages stops without the owner in between. */
201
187
  export function chainLimitReason(who: OwnerIdentity): string {
202
188
  const o = ownerWords(who);
@@ -425,7 +425,7 @@ function toolDefs(
425
425
  name: MESSAGE_AGENT_TOOL,
426
426
  label: "Message agent",
427
427
  description:
428
- 'Send a self-contained request to another agent. It returns at once; the agent works in its own conversation and its answer comes back to you as a new turn, in the conversation you are writing from. An agent has one conversation on each surface (Discord, the web app); by default the request lands in the one it used most recently, so work it started in the web app is answered there. Give conversation to choose another: "discord", "web", or one of the conversation keys agent_get lists for it. The exchange shows in its conversation, and a thread in yours.',
428
+ 'Send a self-contained request to another agent. It returns at once; the agent works in its own conversation and its answer comes back to you as a new turn, in the conversation you are writing from. An agent has one conversation on each surface (Discord, the web app); by default the request lands in the one it used most recently, so work it started in the web app is answered there. Give conversation to choose another: "discord", "web", or one of the conversation keys agent_get lists for it. The request shows in its conversation, and the request and the answer show as small cards in yours.',
429
429
  parameters: Type.Object({
430
430
  to: NAME,
431
431
  text: Type.String({ minLength: 1, maxLength: 4000 }),