pi-roundtable-mcp 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.1] - 2026-10-10
9
+
10
+ ### Fixed
11
+
12
+ - A remote run asked for while the host shuts down fails instead of starting a turn (`defaultConversation().answer` resolves a failed result rather than rejecting). Needs pi-roundtable 0.9.1.
13
+
14
+ ## [0.9.0] - 2026-10-09
15
+
16
+ ### Added
17
+
18
+ - `remoteMcp({ principal })`: the id of the principal the dispatch token stands for, the primary owner by default, as in 0.8. The plugin declares the token as the identity `token:<toolNames.dispatch, or remote-mcp>` in its `identities`, which the host links to that principal at every start; a principal that does not exist, or the identity linked to someone else, stops the start.
19
+ - `RemoteMcpMessages.memberRelayNote`: the note opening each relayed message when the token stands for someone who is not an owner; it falls back to a `relayNote` you give.
20
+
21
+ ### Changed
22
+
23
+ - The connector form of `/<root> connector add` is accepted from every owner, as pi-roundtable's `CommandGuard.allows` sees them, not only the primary owner.
24
+ - A default remote turn is the bound principal's, from `IDENTITY.speakerFor`, at their tier, and its conversation is recorded as private to them. Bound to the primary owner, as by default, the speaker is the owner's principal at the owner tier, so the prompt, memory, and tools are 0.8's; bound to a member, the turn reads their memory and offers only their tier's tools, and the persona and relay note name no owner. The host's own `answer` receives that speaker as a third argument.
25
+ - `remoteMcp` requires `IDENTITY` (provided by the built-in `identity` plugin of pi-roundtable 0.9); a `testPlugin` test of it gives one with `principalOf`, `tierOf`, `speakerFor`, and `owners`.
26
+ - Each remote session belongs to the principal it was opened for (`remote_agent_sessions.principal_id`, migration `remote-sessions-principal`); after the token moves to another principal, continuing a session of the earlier one answers `SESSION_NOT_FOUND`. Sessions 0.8 opened are handed to the primary owner at the start, and their conversations, which 0.8 recorded as shared, are adopted as private to them with `CONVERSATIONS.adopt`, so a single-owner host's remote turns stay 0.8's. Every start adopts each session's conversation for its principal again, so one a failed hand-over left shared becomes theirs at the next start; adopt never changes a conversation that is already someone's. `remoteMcp` therefore requires `CONVERSATIONS` too, and a `testPlugin` test of it gives one with `adopt`.
27
+ - The buttons and menu of `/<root> mcp authorize` and `/<root> mcp token` check again, at each press, that whoever presses is an owner, so an owner revoked while the flow is open completes nothing.
28
+
29
+ ### Deprecated
30
+
31
+ - `REMOTE_SPEAKER` in `default-conversation.ts`: remote turns are for the bound principal. It goes away in 1.0.
32
+
33
+ ### Fixed
34
+
35
+ - The default remote turn finds the actions a restart left held: it reads them with `heldActions`, which restores them from the store, instead of `pendingConfirmation`, which knows only the conversations that ran a turn since startup, so an approval relayed as the first message after a restart confirms them again, as in 0.8. Needs pi-roundtable with the same fix.
36
+
8
37
  ## [0.8.0] - 2026-10-07
9
38
 
10
39
  - Release in lockstep with pi-roundtable 0.8.0; no package-specific behavior changes.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Two plugins for [pi-roundtable](https://github.com/wayne930242/pi-roundtable), t
6
6
  The owner adds an external MCP server (Notion, a calendar, anything that speaks MCP over HTTP) with a private Discord form, and the host gets the servers and a routing description for each, to give its agents.
7
7
  [ContextForge](#contextforge) holds every upstream server and its token.
8
8
  - `remoteMcp`: an MCP server over HTTP for agents outside Discord.
9
- One endpoint relays turns to the owner's agent and returns the answer when polled.
9
+ One endpoint relays turns to the agent, for the owner or another principal the dispatch token stands for, and returns the answer when polled.
10
10
  Another offers Discord channel tools, only for the channels the owner granted on Discord.
11
11
 
12
12
  Bun only, like pi-roundtable.
@@ -20,7 +20,7 @@ The package name and public API are unchanged from the standalone repository.
20
20
  ## What you need
21
21
 
22
22
  - [Bun](https://bun.sh/docs/installation) 1.3 or newer.
23
- - A running pi-roundtable host, version 0.8 (`pi-roundtable` is a peer dependency, `>=0.8.0 <0.9.0`), with its PostgreSQL.
23
+ - A running pi-roundtable host, version 0.9 (`pi-roundtable` is a peer dependency, `>=0.9.0 <0.10.0`), with its PostgreSQL.
24
24
  - For `mcpConnectors`: a [ContextForge](#contextforge) gateway that you run yourself.
25
25
  - For `remoteMcp`: an HTTPS address that reaches the host's `public` listener, such as a tunnel; this is the host's `http.publicUrl`.
26
26
 
@@ -34,6 +34,15 @@ After the lockstep release:
34
34
  bun add pi-roundtable-mcp
35
35
  ```
36
36
 
37
+ ## Principal migration in 0.9
38
+
39
+ Follow [Migrating to 0.9](../../docs/migration-0.9.md) when changing the lockstep core and package together.
40
+ The dispatch token defaults to the primary owner's principal; 0.8 sessions and schedule authors retain that attribution.
41
+ The plugin registers its private scope before runtime use and converges pre-0.9 shared registry records through `CONVERSATIONS.adopt` on startup.
42
+ Explicit `principal` bindings need an existing principal with a lasting role; only `token:` identities may be declared by plugins.
43
+ A custom `answer` must run as the supplied speaker, never silently as the owner, and must record private ownership before a direct runtime call.
44
+ The channel grants of `/mcp/discord/<token>` do not become per-principal grants in this release.
45
+
37
46
  ## Configure
38
47
 
39
48
  List the plugins in `roundtable.config.ts`.
@@ -161,9 +170,10 @@ The owner manages connectors on Discord with `/<root> connector`, where `<root>`
161
170
  | --- | --- | --- | --- |
162
171
  | `dispatchToken` | `string` | required | The bearer token an outside agent presents at `/mcp/personal`. Make it long and keep it secret |
163
172
  | `publicUrl` | `string` | required | The HTTPS address that reaches the host's `public` listener. Granted-channel URLs are built on its origin |
164
- | `persona` | `string` | a short neutral prompt | The system prompt of the default `remote` conversations |
173
+ | `principal` | `string` | the primary owner | The id of the principal the dispatch token stands for: whose memory, schedules, conversations, and tier the remote turns have. See [whom the token stands for](#whom-the-token-stands-for) |
174
+ | `persona` | `string` | a short prompt | The system prompt of the default `remote` conversations. The default names the owner when the token stands for an owner, and no one otherwise |
165
175
  | `answer`, `claim` | see [below](#when-the-host-runs-the-conversations-itself) | the core's runtime | Give both, or neither |
166
- | `messages` | `Partial<RemoteMcpMessages>` | English | The relay note, the tool descriptions, and the Discord text, in your wording. `dispatchDescription` and `resultDescription` are functions that receive the tool names |
176
+ | `messages` | `Partial<RemoteMcpMessages>` | English | The relay notes (`relayNote` for an owner, `memberRelayNote` for anyone else, which falls back to your `relayNote`), the tool descriptions, and the Discord text, in your wording. `dispatchDescription` and `resultDescription` are functions that receive the tool names |
167
177
  | `toolNames` | `{ dispatch?: string; result?: string }` | `agent_dispatch`, `agent_result` | The names of the two tools at `/mcp/personal`, for agents that are already set up with other names. The default descriptions follow them |
168
178
 
169
179
  The plugin serves two endpoints on the host's `public` listener:
@@ -202,10 +212,12 @@ The tables belong to the plugin: change them through the plugin on Discord, or t
202
212
 
203
213
  #### The default conversation
204
214
 
205
- Without `answer` and `claim`, a relayed turn runs on the core: `context.turns.run` of kind `remote`, for an owner-tier speaker, in the channel `mcp:<session>`, through the agent server's runtime.
215
+ Without `answer` and `claim`, a relayed turn runs on the core: `context.turns.run` of kind `remote`, for the principal the dispatch token stands for (`IDENTITY.speakerFor`, at their tier), in the channel `mcp:<session>`, through the agent server's runtime.
216
+ The conversation is recorded as private to that principal.
206
217
  Nothing is posted to Discord, since no chat surface serves `mcp:` channels; the outside agent polls for the answer.
207
- A relayed message that the host's judge reads as approving held actions confirms them, as the owner's own reply would.
208
- Each message begins with a short note that says the owner wrote it in an outside agent.
218
+ A relayed message that the host's judge reads as approving held actions confirms them, as the person's own reply would.
219
+ Each message begins with a short note that says it was written in an outside agent: for an owner, the note and the persona name the owner, word for word as in 0.8; for anyone else they name no owner, so the agent does not take them for one.
220
+ Which of the two is chosen at the start, by whether the principal then holds the owner role.
209
221
  Starting a remote conversation over archives it, and deleting it also ends the outside agent's session.
210
222
  Sessions that stay idle for 14 days are deleted once a day.
211
223
 
@@ -226,9 +238,10 @@ export function hostRemote(dispatchToken: string, publicUrl: string) {
226
238
  return remoteMcp({
227
239
  dispatchToken,
228
240
  publicUrl,
229
- answer: async (channel, text) => ({
241
+ // `speaker` is the principal the dispatch token stands for.
242
+ answer: async (channel, text, speaker) => ({
230
243
  ok: true,
231
- text: `Answered ${text.length} characters in ${channel}.`,
244
+ text: `Answered ${speaker.name}'s ${text.length} characters in ${channel}.`,
232
245
  }),
233
246
  claim: {
234
247
  // The string names whose conversation it was: the host's own kind.
@@ -240,15 +253,34 @@ export function hostRemote(dispatchToken: string, publicUrl: string) {
240
253
  ```
241
254
  <!-- /example -->
242
255
 
243
- - `answer(channel, text)` runs one turn and never rejects.
256
+ - `answer(channel, text, speaker)` runs one turn and never rejects.
257
+ `speaker` is whom the turn is for: the principal the dispatch token stands for, from `IDENTITY.speakerFor`.
258
+ Run the turn as `speaker`, such as by passing it to `context.turns.run`: its tier decides the tools and its principal the memory.
259
+ An `answer` that ignores it, such as one that runs every turn as the owner, gives whoever holds the token owner turns, and `principal` then only decides whose the sessions are.
244
260
  The plugin does not queue it, so it joins the channel's queue itself (`context.queue.run`).
245
261
  The host owns the conversations' kind and persona, so the plugin contributes no persona in this mode.
246
262
  - `claim` is what the claim over the `mcp:<session>` channels does with those conversations: `startFresh` (required; the string it returns is the conversation's kind), `deleteConversation` (required; the plugin removes the session record after it), and optional `stop` and `background`.
247
263
  A claim without `background` skips background turns.
248
264
 
265
+ #### Whom the token stands for
266
+
267
+ The dispatch token is the identity `token:<toolNames.dispatch, or remote-mcp>`, which the plugin declares in its `identities`.
268
+ At every start the host links it to `principal`, or to the primary owner without one, as in 0.8.
269
+ `roundtable principal list` shows it under that principal as `token:remote-mcp (plugin remote-mcp)`, and `roundtable principal unlink` refuses it: change the option instead.
270
+ The start stops when the principal does not exist, or when the identity is linked to someone else by the configuration or the CLI; the error names what to change.
271
+
272
+ Bound to a member, a remote turn reads and changes the member's memory, and only the tools of their tier are offered; when the host runs the conversations itself, that holds only if its `answer` runs the turn as the `speaker` it receives.
273
+ Give that member a lasting role with `roundtable principal grant <principal> member` (or `admin`).
274
+ A remote turn does not record them as seen, so a member whose tier comes only from what a surface reports stops being served `access.backgroundStaleDays` (30 by default) after they were last seen elsewhere: every remote run fails until they are seen again.
275
+
276
+ Changing `principal` and restarting moves the token.
277
+ Each session belongs to the principal it was opened for: after a move, continuing a session of the earlier principal answers `SESSION_NOT_FOUND`, and moving back makes it continue.
278
+ Sessions 0.8 opened belong to the primary owner.
279
+ The tool descriptions the outside agent reads still say "the owner"; give `dispatchDescription` in `messages` for other wording.
280
+
249
281
  ## The grant and security model
250
282
 
251
- - **The dispatch token is the owner's voice.** Whoever holds it can send the owner's agent messages as the owner (owner tier, with every tool the owner's agent has), and can approve its held actions by saying so. Keep it secret, change it by changing the option, and serve the endpoint only over HTTPS.
283
+ - **The dispatch token is the voice of the principal it stands for.** Whoever holds it can send the agent messages as that principal (the primary owner by default: owner tier, with every tool the owner's agent has), and can approve the held actions of its turns by saying so. Keep it secret, change it by changing the option, and serve the endpoint only over HTTPS.
252
284
  - **A bundle URL is a credential.** It carries 32 random bytes; only its SHA-256 hash is stored, and the URL is shown once, when the bundle is created or replaced. Everyone holding it can use every channel in the bundle with the operations granted there, and nothing else.
253
285
  - **The owner approves each grant on Discord.** Only the owner can use the commands. A grant needs the owner to hold *Manage Channels* in the channel, and both the owner and the bot to hold the Discord permissions behind every operation chosen, so a grant never exceeds what both may do. The pending choice expires after five minutes.
254
286
  - **Every call is checked again.** The grant, the guild, and the bundle's token are read after Discord is inspected, so a revoke or a replaced URL stops a call in flight. Each call is written to an audit table before it runs and marked afterwards.
@@ -275,7 +307,7 @@ Operation labels (`read`, `send`, ...) come from pi-roundtable and follow the ho
275
307
  ## Database
276
308
 
277
309
  Each plugin declares its migrations; the host runs them before any setup.
278
- Tables: `owner_connectors`; `discord_mcp_bundles`, `discord_channel_grants`, `discord_channel_audit`, `remote_agent_sessions`.
310
+ Tables: `owner_connectors`; `discord_mcp_bundles`, `discord_channel_grants`, `discord_channel_audit`, `remote_agent_sessions` (with `principal_id`, the migration `remote-sessions-principal`).
279
311
 
280
312
  ## Development
281
313
 
@@ -9,9 +9,10 @@ export function hostRemote(dispatchToken: string, publicUrl: string) {
9
9
  return remoteMcp({
10
10
  dispatchToken,
11
11
  publicUrl,
12
- answer: async (channel, text) => ({
12
+ // `speaker` is the principal the dispatch token stands for.
13
+ answer: async (channel, text, speaker) => ({
13
14
  ok: true,
14
- text: `Answered ${text.length} characters in ${channel}.`,
15
+ text: `Answered ${speaker.name}'s ${text.length} characters in ${channel}.`,
15
16
  }),
16
17
  claim: {
17
18
  // The string names whose conversation it was: the host's own kind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "MCP connectors through ContextForge and a remote MCP endpoint for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,12 +40,12 @@
40
40
  "typebox": "1.3.34"
41
41
  },
42
42
  "peerDependencies": {
43
- "pi-roundtable": ">=0.8.0 <0.9.0"
43
+ "pi-roundtable": ">=0.9.0 <0.10.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@biomejs/biome": "2.5.15",
47
47
  "@types/bun": "1.4.2",
48
- "pi-roundtable": "0.8.0",
48
+ "pi-roundtable": "0.9.1",
49
49
  "typescript": "7.0.2"
50
50
  }
51
51
  }
@@ -314,7 +314,7 @@ export function connectorCommands(
314
314
  interaction.customId !== CONNECTOR_MODAL_ID
315
315
  )
316
316
  return false;
317
- if (!guard.isOwner(interaction)) return true;
317
+ if (!(await guard.allows(interaction))) return true;
318
318
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
319
319
  await guard.run(interaction, () => commands.submit(interaction));
320
320
  return true;
@@ -11,20 +11,42 @@ import type { RemoteClaimHooks } from "./remote-claim.ts";
11
11
  /** The conversation kind of the default remote conversations, and of their persona. */
12
12
  export const REMOTE_KIND = "remote";
13
13
 
14
- /** The system prompt of the default remote conversations, unless the host gives its own. */
14
+ /**
15
+ * The system prompt of the default remote conversations when the dispatch token stands for an
16
+ * owner, unless the host gives its own: 0.8's, word for word.
17
+ */
15
18
  export const DEFAULT_PERSONA =
16
19
  "You are the owner's personal assistant. The owner is writing to you through an outside agent over MCP, not on Discord, and each message begins with a note saying so. Answer the owner directly.";
17
20
 
18
- /** Who the default turns are for: the owner, reached through an outside agent that holds the dispatch token. */
21
+ /**
22
+ * The system prompt of the default remote conversations when the dispatch token stands for someone
23
+ * who is not an owner, unless the host gives its own: it names no owner, so the agent does not
24
+ * take them for one.
25
+ */
26
+ export const MEMBER_PERSONA =
27
+ "You are a personal assistant. The person you serve is writing to you through an outside agent over MCP, not on Discord, and each message begins with a note saying so. Answer them directly.";
28
+
29
+ /**
30
+ * Who the default turns were for in 0.8: the owner, reached through an outside agent that holds
31
+ * the dispatch token.
32
+ * @deprecated The turns are for the principal the dispatch token stands for, as
33
+ * `IDENTITY.speakerFor` gives them; 0.8's `remote-mcp` rows are the primary owner's. Goes away in 1.0.
34
+ */
19
35
  export const REMOTE_SPEAKER: Speaker = {
20
36
  id: "remote-mcp",
21
37
  name: "Remote agent",
22
38
  tier: "owner",
39
+ principalId: "remote-mcp",
23
40
  };
24
41
 
25
42
  /** How the plugin runs a remote turn and a remote conversation's claim operations. */
26
43
  export interface RemoteConversation {
27
- answer(channel: ChannelKey, text: string): Promise<TurnResult>;
44
+ /** Runs one turn for `speaker`, the principal the dispatch token stands for; never rejects. */
45
+ answer(
46
+ channel: ChannelKey,
47
+ text: string,
48
+ speaker: Speaker,
49
+ ): Promise<TurnResult>;
28
50
  claim: RemoteClaimHooks;
29
51
  }
30
52
 
@@ -34,9 +56,10 @@ type Parts = Pick<PluginContext, "queue" | "turns"> & {
34
56
  };
35
57
 
36
58
  /**
37
- * Runs remote turns on the core: `context.turns.run` of kind `remote` for an owner-tier speaker,
38
- * in the channel's queue, with nothing posted since no surface serves `mcp:` channels. A relayed
39
- * message that approves held actions confirms them, as the owner's reply would on Discord.
59
+ * Runs remote turns on the core: `context.turns.run` of kind `remote` for the speaker the token
60
+ * stands for, in a conversation private to them, in the channel's queue, with nothing posted since
61
+ * no surface serves `mcp:` channels. A relayed message that approves held actions confirms them,
62
+ * as the person's own reply would on Discord.
40
63
  */
41
64
  export function defaultConversation({
42
65
  queue,
@@ -44,24 +67,35 @@ export function defaultConversation({
44
67
  server,
45
68
  }: Parts): RemoteConversation {
46
69
  return {
47
- answer: (channel, text) =>
48
- queue.run(channel, () =>
49
- settleTurn(async () => {
50
- const { runtime, approvals } = server();
51
- const pending = runtime.pendingConfirmation(channel);
52
- const confirmed =
53
- pending !== undefined && (await approvals.approves(pending, text));
54
- return turns.run({
55
- channel,
56
- kind: REMOTE_KIND,
57
- text,
58
- speaker: REMOTE_SPEAKER,
59
- confirmed,
60
- // The outside agent polls for the answer; no surface posts it.
61
- reply: async () => undefined,
62
- });
63
- }, "remote turn"),
64
- ),
70
+ answer: (channel, text, speaker) =>
71
+ queue
72
+ .run(channel, () =>
73
+ settleTurn(async () => {
74
+ const { runtime, approvals } = server();
75
+ // Restored from the store: after a restart nothing is held in memory yet.
76
+ const pending = await runtime.heldActions(channel);
77
+ const confirmed =
78
+ pending !== undefined &&
79
+ (await approvals.approves(pending, text));
80
+ return turns.run({
81
+ channel,
82
+ kind: REMOTE_KIND,
83
+ text,
84
+ speaker,
85
+ conversation: { visibility: "private" },
86
+ confirmed,
87
+ // The outside agent polls for the answer; no surface posts it.
88
+ reply: async () => undefined,
89
+ });
90
+ }, "remote turn"),
91
+ )
92
+ // A host shutting down starts no turn: the run fails, and the caller may try again later.
93
+ .catch(
94
+ (error: unknown): TurnResult => ({
95
+ ok: false,
96
+ error: error instanceof Error ? error : new Error(String(error)),
97
+ }),
98
+ ),
65
99
  claim: {
66
100
  stop: (channel) => server().runtime.stop(channel),
67
101
  startFresh: async (channel) => {
@@ -89,7 +89,10 @@ export function mcpGrantCommands(
89
89
  !interaction.customId.startsWith(GRANT_PREFIX)
90
90
  )
91
91
  return false;
92
- await guard.run(interaction, () => flow.component(interaction));
92
+ // The flow outlives the command: an owner revoked since then completes nothing.
93
+ await guard.run(interaction, async () =>
94
+ flow.component(interaction, await guard.allows(interaction)),
95
+ );
93
96
  return true;
94
97
  },
95
98
  }),
@@ -120,14 +120,19 @@ export class McpGrantFlow {
120
120
  );
121
121
  }
122
122
 
123
- /** Buttons and the operations menu of a pending grant or rotation. */
124
- async component(interaction: ButtonOrMenu): Promise<void> {
123
+ /**
124
+ * Buttons and the operations menu of a pending grant or rotation; one pressed by someone who is
125
+ * not an owner now (`owner` false) finds it expired, as anyone's but its starter's does. Whether
126
+ * the presser is an owner is the caller's to check at each press; nothing assumes it.
127
+ */
128
+ async component(interaction: ButtonOrMenu, owner: boolean): Promise<void> {
125
129
  const text = this.#text;
126
130
  const [action, id = ""] = interaction.customId
127
131
  .slice(GRANT_PREFIX.length)
128
132
  .split(":");
129
133
  const pending = this.#pending.get(id);
130
134
  if (
135
+ !owner ||
131
136
  !pending ||
132
137
  pending.userId !== interaction.user.id ||
133
138
  pending.channelId !== interaction.channelId ||
@@ -14,8 +14,13 @@ export const DEFAULT_TOOL_NAMES: RemoteToolNames = {
14
14
  * and what the owner reads in the Discord commands.
15
15
  */
16
16
  export interface RemoteMcpMessages {
17
- /** Opens every relayed turn, so the agent knows where the message comes from. */
17
+ /** Opens every relayed turn, so the agent knows where the message comes from, when the dispatch token stands for an owner. */
18
18
  relayNote: string;
19
+ /**
20
+ * Opens every relayed turn when the dispatch token stands for someone who is not an owner; it
21
+ * names no owner. A host that gives only `relayNote` has it open those turns too.
22
+ */
23
+ memberRelayNote: string;
19
24
  /** The reason an outside agent is told for a run that did not complete. */
20
25
  runFailed: string;
21
26
  /** Sent for a granted tool call that failed in a way the agent must not retry. */
@@ -118,6 +123,8 @@ export interface RemoteMcpMessages {
118
123
  export const REMOTE_MCP_MESSAGES: RemoteMcpMessages = {
119
124
  relayNote:
120
125
  "(The owner wrote this in a personal agent that relays it over MCP, not on Discord. Answer the owner directly, just as you would on Discord; the agent passes your reply back.)",
126
+ memberRelayNote:
127
+ "(This was written in a personal agent that relays it over MCP, not on Discord. Answer the person who wrote it directly, just as you would on Discord; the agent passes your reply back.)",
121
128
  runFailed: "This run did not complete. Try again later.",
122
129
  operationUnfinished:
123
130
  "The operation did not complete. Check the audit log of the grants on Discord; do not retry automatically.",
@@ -1,7 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ChannelKey, Logger, TurnResult } from "pi-roundtable";
3
3
  import { REMOTE_MCP_MESSAGES, type RemoteMcpMessages } from "./messages.ts";
4
- import type { RemoteSessionStore } from "./remote-session-store.ts";
5
4
 
6
5
  /** A dispatch the caller should fix rather than retry as is. */
7
6
  export class RemoteAgentError extends Error {
@@ -23,9 +22,16 @@ interface Run {
23
22
  finishedAt?: number;
24
23
  }
25
24
 
25
+ /** The sessions of the principal the dispatch token stands for. */
26
+ interface RemoteSessions {
27
+ create(): Promise<string>;
28
+ /** Marks their session used; false when they have no such session. */
29
+ touch(id: string): Promise<boolean>;
30
+ }
31
+
26
32
  export interface RemoteAgentOptions {
27
- sessions: Pick<RemoteSessionStore, "create" | "touch">;
28
- /** Runs one owner turn in the session's channel; never rejects. */
33
+ sessions: RemoteSessions;
34
+ /** Runs one turn in the session's channel for the principal the token stands for; never rejects. */
29
35
  answer(channel: ChannelKey, text: string): Promise<TurnResult>;
30
36
  logger: Logger;
31
37
  /** A run still working after this long is reported failed. */
@@ -43,8 +49,9 @@ export const remoteChannel = (sessionId: string): ChannelKey =>
43
49
  `mcp:${sessionId}`;
44
50
 
45
51
  /**
46
- * Turns an outside agent relays for the owner. A dispatch returns at once; the caller polls
47
- * the run. Runs live in memory: a restart forgets them, but not their sessions.
52
+ * Turns an outside agent relays for the principal the dispatch token stands for. A dispatch
53
+ * returns at once; the caller polls the run. Runs live in memory: a restart forgets them, but not
54
+ * their sessions.
48
55
  */
49
56
  export class RemoteAgent {
50
57
  readonly #options: RemoteAgentOptions;
@@ -1,11 +1,16 @@
1
1
  import {
2
2
  AGENTS,
3
3
  type ChannelKey,
4
+ CONVERSATIONS,
4
5
  ConfigError,
5
6
  type Contribution,
6
7
  definePlugin,
8
+ IDENTITY,
9
+ type IdentityService,
7
10
  type PluginContext,
11
+ PluginError,
8
12
  type RoundtablePlugin,
13
+ type Speaker,
9
14
  type TurnResult,
10
15
  } from "pi-roundtable";
11
16
  import { DISCORD } from "pi-roundtable/discord";
@@ -13,6 +18,7 @@ import { ChannelGrantStore } from "./channel-grants.ts";
13
18
  import {
14
19
  DEFAULT_PERSONA,
15
20
  defaultConversation,
21
+ MEMBER_PERSONA,
16
22
  REMOTE_KIND,
17
23
  type RemoteConversation,
18
24
  } from "./default-conversation.ts";
@@ -20,6 +26,7 @@ import { McpGateway } from "./mcp-gateway.ts";
20
26
  import { mcpGrantCommands } from "./mcp-grant-commands.ts";
21
27
  import {
22
28
  DEFAULT_TOOL_NAMES,
29
+ REMOTE_MCP_MESSAGES,
23
30
  type RemoteMcpMessages,
24
31
  type RemoteToolNames,
25
32
  remoteMcpMessages,
@@ -48,11 +55,21 @@ interface RemoteMcpBaseOptions {
48
55
  * `agent_dispatch` and `agent_result` by default. The default descriptions follow the names.
49
56
  */
50
57
  toolNames?: Partial<RemoteToolNames>;
58
+ /**
59
+ * The id of the principal the dispatch token stands for: whose memory, schedules, conversations,
60
+ * and tier the remote turns have. The primary owner, the first of `access.owners`, by default,
61
+ * as in 0.8. The token is the identity `token:<toolNames.dispatch, or remote-mcp>`, which the
62
+ * host links to this principal at every start; a principal that does not exist stops the start.
63
+ */
64
+ principal?: string;
51
65
  }
52
66
 
53
67
  /** Remote turns run on the core's runtime: nothing more to give. */
54
68
  export interface DefaultConversationOptions {
55
- /** The system prompt of the `remote` conversations; a short neutral one by default. */
69
+ /**
70
+ * The system prompt of the `remote` conversations. By default a short one that names the owner
71
+ * when the token stands for an owner at the start, and names no one otherwise.
72
+ */
56
73
  persona?: string;
57
74
  answer?: undefined;
58
75
  claim?: undefined;
@@ -61,11 +78,16 @@ export interface DefaultConversationOptions {
61
78
  /** The host runs the remote turns itself, and says what its conversations do. */
62
79
  export interface HostConversationOptions {
63
80
  /**
64
- * Runs one turn for the owner in the session's channel and never rejects. It runs inside the
65
- * channel's queue (`context.queue.run`) itself, and the conversation it opens has a persona of
66
- * the host's own.
81
+ * Runs one turn in the session's channel and never rejects. It runs inside the channel's queue
82
+ * (`context.queue.run`) itself, and the conversation it opens has a persona of the host's own.
83
+ * `speaker` is whom the turn is for: the principal the dispatch token stands for, from
84
+ * `IDENTITY.speakerFor`.
67
85
  */
68
- answer(channel: ChannelKey, text: string): Promise<TurnResult>;
86
+ answer(
87
+ channel: ChannelKey,
88
+ text: string,
89
+ speaker: Speaker,
90
+ ): Promise<TurnResult>;
69
91
  /** What the claim over the remote channels does with those conversations. */
70
92
  claim: RemoteClaimHooks;
71
93
  persona?: undefined;
@@ -89,6 +111,10 @@ function toolNamesOf(options: RemoteMcpOptions): RemoteToolNames {
89
111
  return names;
90
112
  }
91
113
 
114
+ /** The identity the dispatch token is: `token:<toolNames.dispatch, or remote-mcp>`. */
115
+ const tokenIdentity = (options: RemoteMcpOptions): string =>
116
+ `token:${options.toolNames?.dispatch ?? "remote-mcp"}`;
117
+
92
118
  function checkOptions(options: RemoteMcpOptions): void {
93
119
  if (!options.dispatchToken)
94
120
  throw new ConfigError("remote-mcp: dispatchToken is empty");
@@ -102,33 +128,66 @@ function checkOptions(options: RemoteMcpOptions): void {
102
128
  }
103
129
 
104
130
  /**
105
- * An MCP server over HTTP for outside agents: `/mcp/personal` relays turns to the owner's agent
106
- * and returns the result when polled, and `/mcp/discord/<token>` offers the Discord channel
107
- * tools granted to one bundle. Grants are approved on Discord with `/<root> mcp`.
108
- * Provides `REMOTE_MCP`.
131
+ * An MCP server over HTTP for outside agents: `/mcp/personal` relays turns, for the principal the
132
+ * dispatch token stands for, to the agent and returns the result when polled, and
133
+ * `/mcp/discord/<token>` offers the Discord channel tools granted to one bundle. Grants are
134
+ * approved on Discord with `/<root> mcp`. Provides `REMOTE_MCP`.
109
135
  */
110
136
  export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
111
137
  checkOptions(options);
112
138
  const text = remoteMcpMessages(options.messages);
113
139
  const toolNames = toolNamesOf(options);
140
+ const identity = tokenIdentity(options);
114
141
  return definePlugin({
115
142
  name: "remote-mcp",
116
- requires: [DISCORD],
143
+ requires: [DISCORD, IDENTITY, CONVERSATIONS],
117
144
  provides: [REMOTE_MCP],
118
- migrations: [ChannelGrantStore.migration, RemoteSessionStore.migration],
145
+ identities: [
146
+ {
147
+ identity,
148
+ ...(options.principal === undefined
149
+ ? {}
150
+ : { principal: options.principal }),
151
+ },
152
+ ],
153
+ migrations: [
154
+ ChannelGrantStore.migration,
155
+ ...RemoteSessionStore.migrations(),
156
+ ],
119
157
  setup: async (context) => {
120
158
  const { services, logger, conversations, database } = context;
121
159
  const discord = services.get(DISCORD);
160
+ const identities = services.get(IDENTITY);
161
+ const bound = await boundPrincipal(identities, identity);
122
162
  const conversation = conversationOf(options, context);
123
163
  const grants = await ChannelGrantStore.attach(database());
124
164
  const sessions = await RemoteSessionStore.attach(database());
165
+ // 0.8's sessions were the owner's, and their conversations, recorded shared, become theirs.
166
+ const registry = services.get(CONVERSATIONS);
167
+ const [primary] = await identities.owners();
168
+ if (primary)
169
+ await sessions.adopt(primary.id, async (id) => {
170
+ await registry.adopt(`mcp:${id}`, primary.id);
171
+ });
172
+ // Every start adopts each session's conversation for its principal again, so one a failed
173
+ // hand-over left no one's becomes theirs; adopt never changes one already someone's.
174
+ for (const { id, principalId } of await sessions.owned())
175
+ await registry.adopt(`mcp:${id}`, principalId);
125
176
  const gateway = new McpGateway({
126
177
  dispatchToken: options.dispatchToken,
127
178
  agent: new RemoteAgent({
128
- sessions,
129
- answer: conversation.answer,
179
+ sessions: {
180
+ create: () => sessions.create(bound.id),
181
+ touch: (id) => sessions.touch(id, bound.id),
182
+ },
183
+ answer: async (channel, relayed) =>
184
+ conversation.answer(
185
+ channel,
186
+ relayed,
187
+ await identities.speakerFor(bound.id),
188
+ ),
130
189
  logger,
131
- messages: text,
190
+ messages: { ...text, relayNote: relayNoteOf(options, bound) },
132
191
  }),
133
192
  grants,
134
193
  executor: () => discord.connection.channelExecutor(),
@@ -156,12 +215,42 @@ export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
156
215
  ],
157
216
  http: gateway.routes(LISTENER),
158
217
  channels: [remoteClaim(conversation.claim, sessions)],
159
- ...personaOf(options),
218
+ ...personaOf(options, bound),
160
219
  } satisfies Contribution;
161
220
  },
162
221
  });
163
222
  }
164
223
 
224
+ /** The principal the dispatch token stands for, and whether they held the owner role at the start. */
225
+ interface Bound {
226
+ id: string;
227
+ owner: boolean;
228
+ }
229
+
230
+ /** Whom the identity plugin linked the dispatch token to at this start. */
231
+ async function boundPrincipal(
232
+ identities: IdentityService,
233
+ identity: string,
234
+ ): Promise<Bound> {
235
+ const id = await identities.principalOf(identity);
236
+ if (id === undefined)
237
+ throw new PluginError(
238
+ `remote-mcp: ${identity}, the dispatch token's identity, is linked to no principal. The built-in identity plugin links it at the start; an IDENTITY that replaces it must link the identities plugins declare.`,
239
+ );
240
+ return { id, owner: (await identities.tierOf(id)) === "owner" };
241
+ }
242
+
243
+ /** The note opening every relayed message: the owner's, or for anyone else one that names no owner. */
244
+ function relayNoteOf(options: RemoteMcpOptions, bound: Bound): string {
245
+ const given = options.messages;
246
+ if (bound.owner) return given?.relayNote ?? REMOTE_MCP_MESSAGES.relayNote;
247
+ return (
248
+ given?.memberRelayNote ??
249
+ given?.relayNote ??
250
+ REMOTE_MCP_MESSAGES.memberRelayNote
251
+ );
252
+ }
253
+
165
254
  /** The host's own turns, or the default ones over the core's runtime. */
166
255
  function conversationOf(
167
256
  options: RemoteMcpOptions,
@@ -171,9 +260,16 @@ function conversationOf(
171
260
  return defaultConversation({ queue, turns, server: services.lazy(AGENTS) });
172
261
  }
173
262
 
174
- /** The persona of the default conversations; a host that runs its own brings its own. */
175
- function personaOf(options: RemoteMcpOptions): Pick<Contribution, "personas"> {
263
+ /**
264
+ * The persona of the default conversations, the owner's or one that names no owner, as the token
265
+ * stands for at the start; a host that runs its own brings its own.
266
+ */
267
+ function personaOf(
268
+ options: RemoteMcpOptions,
269
+ bound: Bound,
270
+ ): Pick<Contribution, "personas"> {
176
271
  if (options.answer) return {};
177
- const prompt = options.persona ?? DEFAULT_PERSONA;
272
+ const prompt =
273
+ options.persona ?? (bound.owner ? DEFAULT_PERSONA : MEMBER_PERSONA);
178
274
  return { personas: [{ kind: REMOTE_KIND, prompt: () => prompt }] };
179
275
  }
@@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import type { SQL } from "bun";
3
3
  import type { Migration } from "pi-roundtable";
4
4
 
5
- /** The conversations outside agents opened with the owner's agent, so they survive a restart. */
5
+ /**
6
+ * The conversations outside agents opened with the agent, each the principal's the dispatch token
7
+ * stood for when it was opened, so they survive a restart and stay theirs.
8
+ */
6
9
  export class RemoteSessionStore {
7
10
  readonly #sql: SQL;
8
11
 
@@ -23,21 +26,66 @@ export class RemoteSessionStore {
23
26
  },
24
27
  };
25
28
 
29
+ /** Whose each session is: null for one 0.8 opened, which `adopt` gives the primary owner. */
30
+ static readonly principalMigration: Migration = {
31
+ name: "remote-sessions-principal",
32
+ up: async (sql) => {
33
+ await sql`ALTER TABLE remote_agent_sessions ADD COLUMN IF NOT EXISTS principal_id text`;
34
+ },
35
+ };
36
+
37
+ /** Both, in order: what a pool needs before the store attaches. */
38
+ static migrations(): Migration[] {
39
+ return [
40
+ RemoteSessionStore.migration,
41
+ RemoteSessionStore.principalMigration,
42
+ ];
43
+ }
44
+
26
45
  /** The store over the host's migrated pool. */
27
46
  static async attach(sql: SQL): Promise<RemoteSessionStore> {
28
47
  return new RemoteSessionStore(sql);
29
48
  }
30
49
 
31
- async create(): Promise<string> {
50
+ /**
51
+ * Gives the sessions of no principal, which 0.8 opened for the owner, to the primary owner,
52
+ * calling `handOver` with each one's id before it commits: a `handOver` that throws leaves them
53
+ * all unowned for the next start to hand over again. Resolves the ids handed over.
54
+ */
55
+ async adopt(
56
+ principalId: string,
57
+ handOver: (id: string) => Promise<void>,
58
+ ): Promise<string[]> {
59
+ return this.#sql.begin(async (tx) => {
60
+ // The row locks make a start racing this one wait, then find none left to hand over.
61
+ const rows: { id: string }[] = await tx`
62
+ UPDATE remote_agent_sessions SET principal_id = ${principalId}
63
+ WHERE principal_id IS NULL RETURNING id`;
64
+ for (const { id } of rows) await handOver(id);
65
+ return rows.map((row) => row.id);
66
+ });
67
+ }
68
+
69
+ /** Every session that is someone's, with whose it is. */
70
+ async owned(): Promise<{ id: string; principalId: string }[]> {
71
+ return this.#sql`
72
+ SELECT id, principal_id AS "principalId" FROM remote_agent_sessions
73
+ WHERE principal_id IS NOT NULL ORDER BY id`;
74
+ }
75
+
76
+ async create(principalId: string): Promise<string> {
32
77
  const id = randomUUID();
33
- await this.#sql`INSERT INTO remote_agent_sessions (id) VALUES (${id})`;
78
+ await this.#sql`
79
+ INSERT INTO remote_agent_sessions (id, principal_id) VALUES (${id}, ${principalId})`;
34
80
  return id;
35
81
  }
36
82
 
37
- /** Marks the session used; resolves false when it does not exist. */
38
- async touch(id: string): Promise<boolean> {
83
+ /** Marks the principal's session used; resolves false when they have no such session. */
84
+ async touch(id: string, principalId: string): Promise<boolean> {
39
85
  const rows = await this.#sql`
40
- UPDATE remote_agent_sessions SET last_used_at = now() WHERE id = ${id} RETURNING id`;
86
+ UPDATE remote_agent_sessions SET last_used_at = now()
87
+ WHERE id = ${id} AND principal_id = ${principalId}
88
+ RETURNING id`;
41
89
  return rows.length > 0;
42
90
  }
43
91
 
@@ -46,7 +94,7 @@ export class RemoteSessionStore {
46
94
  await this.#sql`DELETE FROM remote_agent_sessions WHERE id = ${id}`;
47
95
  }
48
96
 
49
- /** Sessions last used before the cutoff. */
97
+ /** Sessions last used before the cutoff, whoever's they are. */
50
98
  async idleSince(cutoff: Date): Promise<string[]> {
51
99
  const rows: { id: string }[] = await this.#sql`
52
100
  SELECT id FROM remote_agent_sessions WHERE last_used_at < ${cutoff} ORDER BY last_used_at`;