pi-roundtable 0.1.0 → 0.2.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.
Files changed (239) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/README.zh-TW.md +144 -0
  4. package/docs/plugins.md +1448 -66
  5. package/examples/echo-runtime.test.ts +122 -0
  6. package/examples/echo-runtime.ts +107 -0
  7. package/examples/events.test.ts +1 -0
  8. package/examples/events.ts +2 -2
  9. package/examples/fake-surface.test.ts +180 -0
  10. package/examples/fake-surface.ts +109 -0
  11. package/examples/interactions.test.ts +12 -3
  12. package/examples/interactions.ts +19 -19
  13. package/examples/shared-services.test.ts +74 -0
  14. package/examples/shared-services.ts +60 -0
  15. package/examples/study-room.test.ts +69 -0
  16. package/examples/study-room.ts +52 -0
  17. package/examples/support-desk.test.ts +34 -0
  18. package/examples/support-desk.ts +40 -0
  19. package/package.json +11 -3
  20. package/src/cli/checks/database.ts +11 -30
  21. package/src/cli/checks/images.ts +19 -0
  22. package/src/cli/doctor.ts +6 -0
  23. package/src/cli/main.ts +2 -1
  24. package/src/core/agents/agent-claim.ts +42 -16
  25. package/src/core/agents/agent-messages.ts +4 -4
  26. package/src/core/agents/agent-ports.ts +8 -21
  27. package/src/core/agents/agent-prompt.ts +3 -1
  28. package/src/core/agents/agent-store.ts +6 -4
  29. package/src/core/agents/agent-team-fixture.ts +389 -0
  30. package/src/core/agents/agent-team.ts +26 -25
  31. package/src/core/agents/agent-tools.ts +34 -15
  32. package/src/core/agents/avatar-studio.ts +40 -6
  33. package/src/core/agents/fallback-avatar.ts +85 -0
  34. package/src/core/agents/team-editing.ts +49 -10
  35. package/src/core/agents/team-keys.ts +22 -9
  36. package/src/core/agents/team-layout.ts +4 -3
  37. package/src/core/agents/team-lifecycle.ts +28 -2
  38. package/src/core/agents/team-options.ts +9 -5
  39. package/src/core/agents/team-status.ts +4 -4
  40. package/src/core/agents/team-text.ts +8 -6
  41. package/src/core/agents/team-turn-types.ts +4 -4
  42. package/src/core/agents/team-turns.ts +6 -5
  43. package/src/core/builtin/agent-server.ts +187 -121
  44. package/src/core/builtin/discord-admin.ts +41 -0
  45. package/src/core/builtin/discord.ts +65 -40
  46. package/src/core/builtin/modules.ts +67 -58
  47. package/src/core/builtin/session-tool.ts +24 -0
  48. package/src/core/builtin/skills.ts +72 -0
  49. package/src/core/builtin/stores.ts +55 -31
  50. package/src/core/config/config.ts +45 -4
  51. package/src/core/config/schema.ts +6 -0
  52. package/src/core/contract/channels.ts +54 -14
  53. package/src/core/contract/providers.ts +17 -0
  54. package/src/core/contract/runtime.ts +138 -0
  55. package/src/core/contract/services.ts +45 -0
  56. package/src/core/contract/surface.ts +88 -0
  57. package/src/core/db/migrations.ts +118 -3
  58. package/src/core/define-roundtable.ts +70 -46
  59. package/src/core/define.ts +2 -1
  60. package/src/core/discord/agent-commands.ts +39 -14
  61. package/src/core/discord/agent-panel.ts +66 -0
  62. package/src/core/discord/channel-executor.ts +6 -3
  63. package/src/core/discord/channel-operations.ts +14 -11
  64. package/src/core/discord/command-collection.ts +46 -0
  65. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  66. package/src/core/discord/connection.ts +35 -0
  67. package/src/core/discord/discord-surface.ts +32 -94
  68. package/src/core/discord/dispatch-threads.ts +2 -2
  69. package/src/core/discord/inbound-message.ts +99 -0
  70. package/src/core/discord/interaction-module.ts +57 -1
  71. package/src/core/discord/owner-cards.ts +4 -4
  72. package/src/core/discord/owner-command.ts +44 -11
  73. package/src/core/discord/owner-discord.ts +2 -2
  74. package/src/core/discord/schedule-commands.ts +21 -18
  75. package/src/core/discord/stop-button.ts +44 -1
  76. package/src/core/domain/attachment.ts +12 -8
  77. package/src/core/domain/conversation.ts +3 -4
  78. package/src/core/domain/owner-prompts.ts +5 -5
  79. package/src/core/domain/ports.ts +9 -53
  80. package/src/core/freeze.ts +9 -0
  81. package/src/core/host.ts +255 -82
  82. package/src/core/http/listeners.ts +55 -20
  83. package/src/core/i18n/agent-panel.ts +4 -0
  84. package/src/core/i18n/index.ts +12 -2
  85. package/src/core/i18n/owner.ts +7 -1
  86. package/src/core/i18n/schedules.ts +12 -6
  87. package/src/core/identity.ts +1 -1
  88. package/src/core/judging/effort-judge.ts +25 -3
  89. package/src/core/log.ts +21 -3
  90. package/src/core/models.ts +2 -2
  91. package/src/core/modules/background/background-turns.ts +6 -4
  92. package/src/core/modules/delegation/delegate.ts +3 -2
  93. package/src/core/modules/delegation/delegator.ts +15 -13
  94. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  95. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  96. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  97. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  98. package/src/core/modules/memory/owner-memory.ts +9 -13
  99. package/src/core/modules/schedules/recurrence.ts +5 -3
  100. package/src/core/modules/schedules/schedule-store.ts +11 -11
  101. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  102. package/src/core/modules/schedules/scheduler.ts +7 -2
  103. package/src/core/modules/schedules/schedules.ts +9 -3
  104. package/src/core/modules/skills/skill-registry.ts +3 -2
  105. package/src/core/modules/skills/skill-store.ts +2 -15
  106. package/src/core/modules/skills/skill-tools.ts +10 -4
  107. package/src/core/ops/error-reporter.ts +1 -1
  108. package/src/core/plugin.ts +232 -28
  109. package/src/core/registry/contributions.ts +173 -14
  110. package/src/core/registry/providers.ts +35 -5
  111. package/src/core/registry/services.ts +229 -0
  112. package/src/core/routing/channel-queue.ts +5 -0
  113. package/src/core/routing/channel-router.ts +21 -7
  114. package/src/core/routing/conversation-turns.ts +139 -0
  115. package/src/core/routing/message-text.ts +9 -2
  116. package/src/core/routing/surface-port.ts +39 -0
  117. package/src/core/runtime/conversation-sessions.ts +4 -3
  118. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  119. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  120. package/src/core/runtime/mcp.ts +1 -1
  121. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  122. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  123. package/src/core/runtime/prompt-slot.ts +8 -3
  124. package/src/core/runtime/runtime-types.ts +9 -35
  125. package/src/core/runtime/session-factory.ts +21 -5
  126. package/src/core/runtime/text-tools.ts +1 -3
  127. package/src/core/services.ts +238 -90
  128. package/src/core/sessions.ts +3 -2
  129. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  130. package/src/core/shared/delegate-tool.ts +4 -3
  131. package/src/core/shared/schedule-tools.ts +22 -13
  132. package/src/core/shared/session-messages.ts +1 -1
  133. package/src/core/speakers.ts +6 -38
  134. package/src/core/testing/database.ts +1 -7
  135. package/src/core/testing/eager-catalog.ts +30 -0
  136. package/src/core/testing/locale.ts +23 -5
  137. package/src/core/testing/modules.ts +105 -41
  138. package/src/core/testing/test-host.ts +279 -0
  139. package/src/core/testing/tool-set.ts +64 -0
  140. package/src/core/time.ts +6 -1
  141. package/src/core/tool-set.snapshot.json +288 -0
  142. package/src/core/tool-tiers.ts +17 -57
  143. package/src/discord/index.ts +64 -0
  144. package/src/index.ts +181 -10
  145. package/src/kit/channels.ts +14 -0
  146. package/src/kit/domain.ts +18 -0
  147. package/src/kit/holds.ts +4 -0
  148. package/src/kit/index.ts +129 -0
  149. package/src/kit/judging.ts +10 -0
  150. package/src/kit/memory.ts +3 -0
  151. package/src/kit/mirror.ts +19 -0
  152. package/src/kit/presentation.ts +7 -0
  153. package/src/kit/shell.ts +7 -0
  154. package/src/kit/skills.ts +8 -0
  155. package/src/kit/support.ts +21 -0
  156. package/src/kit/threads.ts +7 -0
  157. package/src/kit/tools.ts +14 -0
  158. package/src/kit/worker.ts +16 -0
  159. package/src/testing.ts +394 -34
  160. package/examples/guide.test.ts +0 -52
  161. package/src/cli/add-plugin.test.ts +0 -107
  162. package/src/cli/checks/basic.test.ts +0 -276
  163. package/src/cli/checks/database.test.ts +0 -117
  164. package/src/cli/checks/discord.test.ts +0 -200
  165. package/src/cli/cli.test.ts +0 -122
  166. package/src/cli/config-edit.test.ts +0 -130
  167. package/src/cli/doctor.test.ts +0 -184
  168. package/src/cli/init.test.ts +0 -127
  169. package/src/cli/size.test.ts +0 -9
  170. package/src/cli/templates.test.ts +0 -129
  171. package/src/cli/testing/fixtures.ts +0 -106
  172. package/src/core/agents/agent-claim.test.ts +0 -142
  173. package/src/core/agents/agent-dashboard.test.ts +0 -120
  174. package/src/core/agents/agent-guild.test.ts +0 -124
  175. package/src/core/agents/agent-store.test.ts +0 -205
  176. package/src/core/agents/avatar-studio.test.ts +0 -88
  177. package/src/core/agents/group-round.test.ts +0 -143
  178. package/src/core/agents/owner-identity.test.ts +0 -172
  179. package/src/core/agents/team-turns.test.ts +0 -278
  180. package/src/core/attachments/attachments.test.ts +0 -85
  181. package/src/core/boundary.test.ts +0 -45
  182. package/src/core/builtin/modules.test.ts +0 -127
  183. package/src/core/config/config.test.ts +0 -135
  184. package/src/core/contract/discord.ts +0 -33
  185. package/src/core/db/migrations.test.ts +0 -237
  186. package/src/core/define-roundtable.test.ts +0 -134
  187. package/src/core/define.test.ts +0 -144
  188. package/src/core/discord/agent-commands.test.ts +0 -58
  189. package/src/core/discord/agent-discord.test.ts +0 -70
  190. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  191. package/src/core/discord/dispatch-threads.test.ts +0 -94
  192. package/src/core/discord/owner-cards.test.ts +0 -435
  193. package/src/core/discord/owner-discord.test.ts +0 -325
  194. package/src/core/domain/expression.ts +0 -21
  195. package/src/core/domain/profile.ts +0 -32
  196. package/src/core/drain.test.ts +0 -46
  197. package/src/core/events.test.ts +0 -81
  198. package/src/core/holds.test.ts +0 -54
  199. package/src/core/host.test.ts +0 -536
  200. package/src/core/http/listeners.test.ts +0 -164
  201. package/src/core/i18n/i18n.test.ts +0 -139
  202. package/src/core/identity.test.ts +0 -18
  203. package/src/core/judging/effort-judge.test.ts +0 -112
  204. package/src/core/judging/model-judge.test.ts +0 -126
  205. package/src/core/modules/delegation/delegator.test.ts +0 -170
  206. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  207. package/src/core/modules/schedules/schedule.test.ts +0 -358
  208. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  209. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  210. package/src/core/ops/error-reporter.test.ts +0 -283
  211. package/src/core/presentation/card-cadence.ts +0 -41
  212. package/src/core/presentation/presentation.test.ts +0 -166
  213. package/src/core/public-entry.test.ts +0 -59
  214. package/src/core/registry/contributions.test.ts +0 -215
  215. package/src/core/registry/interactions.test.ts +0 -87
  216. package/src/core/registry/providers.test.ts +0 -103
  217. package/src/core/routing/channel-queue.test.ts +0 -31
  218. package/src/core/routing/channel-router.test.ts +0 -326
  219. package/src/core/routing/conversation-kind.ts +0 -14
  220. package/src/core/routing/settle-turn.test.ts +0 -21
  221. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  222. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  223. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  224. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  225. package/src/core/runtime/session-archive.test.ts +0 -20
  226. package/src/core/runtime/steerable-run.test.ts +0 -264
  227. package/src/core/runtime/text-tools.test.ts +0 -110
  228. package/src/core/runtime/turn-answer.test.ts +0 -72
  229. package/src/core/runtime/worker-task.test.ts +0 -82
  230. package/src/core/services.test.ts +0 -28
  231. package/src/core/sessions.test.ts +0 -85
  232. package/src/core/shared/unix-server.ts +0 -18
  233. package/src/core/size.test.ts +0 -9
  234. package/src/core/speakers.test.ts +0 -78
  235. package/src/core/testing/file-size.ts +0 -34
  236. package/src/core/time.test.ts +0 -166
  237. package/src/core/tool-tiers.test.ts +0 -117
  238. package/src/entries.test.ts +0 -112
  239. package/src/testing.test.ts +0 -156
package/src/core/host.ts CHANGED
@@ -1,40 +1,63 @@
1
1
  import type { SQL } from "bun";
2
2
  import type { ConversationPort } from "./contract/channels.ts";
3
- import type { CommandRoot } from "./contract/discord.ts";
4
- import { migrate, openPool } from "./db/migrations.ts";
3
+ import type { SurfacePort } from "./contract/surface.ts";
4
+ import { openPool, runMigrations } from "./db/migrations.ts";
5
5
  import { type DrainOptions, waitUntilIdle } from "./drain.ts";
6
6
  import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
7
7
  import { EventBus } from "./events.ts";
8
8
  import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
9
+ import { type Locale, setLocale } from "./i18n/index.ts";
9
10
  import type { JudgeModel } from "./judging/model-judge.ts";
10
11
  import type { Logger } from "./log.ts";
11
12
  import type {
12
- AgentServerOutcome,
13
+ HostEnv,
13
14
  LinkedSessions,
14
15
  RoundtablePlugin,
16
+ Service,
17
+ ServiceStartedEvent,
15
18
  } from "./plugin.ts";
19
+ import { refuseRemovedFields } from "./plugin.ts";
16
20
  import {
17
21
  collectContributions,
18
22
  emptyRegistry,
19
23
  linkSessions,
20
24
  type Registry,
21
25
  } from "./registry/contributions.ts";
22
- import { composeInteractions } from "./registry/interactions.ts";
23
26
  import { resolveProviders } from "./registry/providers.ts";
27
+ import { replaceServices, ServiceRegistry } from "./registry/services.ts";
24
28
  import { ChannelQueue } from "./routing/channel-queue.ts";
25
29
  import { ChannelRouter } from "./routing/channel-router.ts";
26
- import { CoreRegistry } from "./services.ts";
30
+ import {
31
+ type ConversationTurns,
32
+ conversationTurns,
33
+ } from "./routing/conversation-turns.ts";
34
+ import { surfacePort } from "./routing/surface-port.ts";
35
+ import { AGENTS } from "./services.ts";
36
+ import { setTimeZone } from "./time.ts";
27
37
  import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
28
38
 
39
+ /**
40
+ * What differs between hosts: the words and time the process speaks in, and the Pi agent
41
+ * directory its packages read. `run()` applies them to the process when the host starts, and
42
+ * plugins read the locale and zone from `PluginContext.env`.
43
+ */
44
+ export interface HostEnvironment {
45
+ /** The language of the Discord text; default en. */
46
+ locale?: Locale;
47
+ /** An IANA time zone; default UTC. */
48
+ timeZone?: string;
49
+ /** The assistant's display name in the text; default Roundtable. */
50
+ assistant?: string;
51
+ /** The name of the root slash command in the text, without the slash; default roundtable. */
52
+ rootCommand?: string;
53
+ /** Exported as `PI_CODING_AGENT_DIR` for Pi packages such as pi-web-access; unset leaves the process's own. */
54
+ agentDir?: string;
55
+ }
56
+
29
57
  export interface RoundtableOptions {
30
58
  logger: Logger;
31
- /**
32
- * The root command the plugins' subcommands go under; the composed commands go to the plugins'
33
- * `useCommands` before any service starts, since Discord registers them as it connects.
34
- */
35
- commands?: {
36
- root: CommandRoot;
37
- };
59
+ /** The host's locale, time zone and names. Nothing is applied before `run()`. */
60
+ environment?: HostEnvironment;
38
61
  /** The HTTP listeners plugins attach routes to. */
39
62
  listeners?: readonly ListenerConfig[];
40
63
  /** How long a bare forward waits for the message it follows. */
@@ -51,9 +74,13 @@ export interface RoundtableOptions {
51
74
  aborted?: (left: string[]) => Promise<void>;
52
75
  /** The drain's limit and clock; tests shorten them. */
53
76
  drain?: Omit<DrainOptions, "busy">;
77
+ /** What `listen()` calls with the shutdown's exit code; the process's own exit by default. */
54
78
  exit?: (code: number) => void;
55
79
  }
56
80
 
81
+ /** The host running in this process; the text catalog, time zone and environment are process-wide. */
82
+ let running: Roundtable | undefined;
83
+
57
84
  type Attempt = { ok: true } | { ok: false; error: unknown };
58
85
 
59
86
  /** Runs one step whose failure the caller reports and moves past. */
@@ -66,31 +93,48 @@ async function attempt(step: () => Promise<void> | void): Promise<Attempt> {
66
93
  }
67
94
  }
68
95
 
69
- /** Starts the agent server, then tells every plugin how that went; a failing handler never stops the others. */
70
- async function startAgentServer(
71
- start: () => Promise<void>,
72
- handlers: Registry["handlers"],
96
+ /**
97
+ * Runs every service's background start at once and tells every plugin, as each ends, how it
98
+ * went; the rest of the process runs either way, and a failing start or handler never stops the
99
+ * others.
100
+ */
101
+ async function startInBackground(
102
+ registry: Registry,
73
103
  logger: Logger,
74
104
  ): Promise<void> {
75
- const started = await attempt(start);
76
- // The rest of the process runs on without the agent server's new channels.
77
- if (started.ok) logger.info("agent server ready");
78
- else logger.error({ err: started.error }, "agent server did not start");
79
- const outcome: AgentServerOutcome = started.ok ? "ready" : "failed";
80
- for (const { plugin, events } of handlers) {
81
- const handled = await attempt(() => events.agentServer?.(outcome));
82
- if (!handled.ok)
83
- logger.error(
84
- { plugin, err: handled.error },
85
- "agent server handler failed",
86
- );
87
- }
105
+ const { services, servicePlugins, handlers } = registry;
106
+ await Promise.all(
107
+ services.map(async (service) => {
108
+ if (!service.startInBackground) return;
109
+ const plugin = servicePlugins.get(service) ?? "unknown";
110
+ const started = await attempt(() => service.startInBackground?.());
111
+ if (started.ok) logger.info({ plugin, service: service.name }, "ready");
112
+ else
113
+ logger.error(
114
+ { plugin, service: service.name, err: started.error },
115
+ "service did not start in the background",
116
+ );
117
+ const event: ServiceStartedEvent = {
118
+ plugin,
119
+ service: service.name,
120
+ outcome: started.ok ? "ready" : "failed",
121
+ };
122
+ for (const { plugin: heard, events } of handlers) {
123
+ const handled = await attempt(() => events.serviceStarted?.(event));
124
+ if (!handled.ok)
125
+ logger.error(
126
+ { plugin: heard, err: handled.error },
127
+ "service started handler failed",
128
+ );
129
+ }
130
+ }),
131
+ );
88
132
  }
89
133
 
90
134
  /**
91
135
  * The process around the plugins: it sets them all up, links what they add (session parts,
92
- * commands, HTTP routes) and runs the preflight, then starts their services in order and the
93
- * HTTP listeners last, and starts the agent server. Nothing reaches Discord or a listener unless
136
+ * HTTP routes) and runs the preflight, then starts their services in order and the
137
+ * HTTP listeners last, then runs the services' background starts. Nothing reaches Discord or a listener unless
94
138
  * every setup, link, and the preflight succeeded. On shutdown it waits until no work runs or
95
139
  * waits before closing the listeners and stopping the services in reverse.
96
140
  */
@@ -106,12 +150,23 @@ export class Roundtable {
106
150
  readonly #queue = new ChannelQueue();
107
151
  readonly #tiers: ToolTierTable;
108
152
  readonly #events: EventBus;
109
- readonly #core = new CoreRegistry();
153
+ #services: ServiceRegistry | undefined;
154
+ /** The plugins that run: the registered ones, less those a replacement dropped, in set-up order. */
155
+ #active: readonly RoundtablePlugin[] = [];
156
+ /** The services whose start finished, in start order. */
157
+ #started: Service[] = [];
158
+ #booting: Promise<void> | undefined;
159
+ #bootFailed = false;
160
+ #stopping: Promise<number> | undefined;
110
161
 
111
162
  constructor(
112
163
  options: RoundtableOptions,
113
164
  plugins: readonly RoundtablePlugin[],
114
165
  ) {
166
+ if ("commands" in options)
167
+ throw new PluginError(
168
+ "RoundtableOptions.commands was removed in 0.2.0; the host composes no commands. The Discord plugin composes them under the root command named by config discord.rootCommand, and a plugin adds its own with context.services.get(DISCORD).commands.add(...), with DISCORD from pi-roundtable/discord.",
169
+ );
115
170
  this.#options = options;
116
171
  this.#plugins = plugins;
117
172
  this.#tiers = options.toolTiers ?? toolTiers();
@@ -130,6 +185,7 @@ export class Roundtable {
130
185
  return {
131
186
  handle: (message) => router().handle(message),
132
187
  background: (turn) => router().background(turn),
188
+ target: (name) => router().target(name),
133
189
  startFresh: (channel) => router().startFresh(channel),
134
190
  deleteConversation: (channel) => router().deleteConversation(channel),
135
191
  stop: (channel) => router().stop(channel),
@@ -137,19 +193,98 @@ export class Roundtable {
137
193
  };
138
194
  }
139
195
 
196
+ /** The contributed surfaces by channel prefix; every call before linking throws NotLinkedError. */
197
+ #surfaces(): SurfacePort {
198
+ return surfacePort(() => {
199
+ if (!this.#sessions)
200
+ throw new NotLinkedError(
201
+ "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
202
+ );
203
+ return this.#registry.surfaces;
204
+ });
205
+ }
206
+
207
+ /** Turns over the agent server's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
208
+ #turns(): ConversationTurns {
209
+ return conversationTurns({
210
+ linked: () => {
211
+ if (!this.#sessions)
212
+ throw new NotLinkedError(
213
+ "conversation turns are linked once every plugin is set up. Use turns from a service's start or from a handler, not during setup.",
214
+ );
215
+ },
216
+ runtime: () => {
217
+ if (!this.#services)
218
+ throw new NotLinkedError("the host has not started yet.");
219
+ return this.#services.get(AGENTS).runtime;
220
+ },
221
+ surfaces: this.#surfaces(),
222
+ events: this.#events.sink,
223
+ selection: () => {
224
+ if (!this.#sessions)
225
+ throw new NotLinkedError("session parts are not linked yet.");
226
+ return this.#sessions.agentSelection();
227
+ },
228
+ logger: this.#options.logger,
229
+ });
230
+ }
231
+
140
232
  /**
141
233
  * Sets up every plugin, links what they add and runs the preflight, refusing any clash before
142
234
  * anything starts, then starts the services and opens the listeners; the agent server starts
143
- * in the background.
235
+ * in the background. One host runs per process: a second `run()` is refused until the first
236
+ * host has stopped. A start that fails stops what it started, in reverse, closes the pool,
237
+ * and rethrows, so the same host may try again.
144
238
  */
145
239
  async run(): Promise<void> {
146
- const { logger, commands, listeners = [] } = this.#options;
147
- const providers = resolveProviders(this.#plugins, this.#options.judgeModel);
240
+ if (running)
241
+ throw new PluginError(
242
+ "a Roundtable host is already running in this process. Stop the first host, or run this one in a separate process.",
243
+ );
244
+ running = this;
245
+ this.#stopping = undefined;
246
+ this.#bootFailed = false;
247
+ this.#booting = this.#boot();
248
+ try {
249
+ await this.#booting;
250
+ } catch (error) {
251
+ this.#bootFailed = true;
252
+ await this.#teardown();
253
+ running = undefined;
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ /** Applies the host's environment to the process and returns what plugins read of it. */
259
+ #applyEnvironment(): HostEnv {
260
+ const {
261
+ locale = "en",
262
+ timeZone = "UTC",
263
+ assistant = "Roundtable",
264
+ rootCommand = "roundtable",
265
+ agentDir,
266
+ } = this.#options.environment ?? {};
267
+ setLocale(locale, { assistant, root: rootCommand });
268
+ setTimeZone(timeZone);
269
+ // Pi packages such as pi-web-access read their config from the Pi agent directory.
270
+ if (agentDir !== undefined) process.env.PI_CODING_AGENT_DIR = agentDir;
271
+ return { locale, timeZone, now: () => new Date() };
272
+ }
273
+
274
+ async #boot(): Promise<void> {
275
+ const { logger, listeners = [] } = this.#options;
276
+ for (const plugin of this.#plugins) refuseRemovedFields(plugin);
277
+ const env = this.#applyEnvironment();
278
+ // A plugin that replaces a service takes the place of the one that provided it.
279
+ this.#active = replaceServices(this.#plugins);
280
+ this.#services = new ServiceRegistry(this.#active);
281
+ const providers = resolveProviders(this.#active, this.#options.judgeModel);
148
282
  await this.#migrate();
149
283
  this.#registry = await collectContributions(
150
- this.#plugins,
284
+ this.#active,
151
285
  {
152
286
  logger,
287
+ env,
153
288
  sessions: () => {
154
289
  if (!this.#sessions)
155
290
  throw new NotLinkedError(
@@ -161,12 +296,13 @@ export class Roundtable {
161
296
  toolTiers: this.#tiers,
162
297
  events: this.#events.sink,
163
298
  conversations: this.#conversations(),
299
+ surfaces: this.#surfaces(),
300
+ turns: this.#turns(),
164
301
  database: () => {
165
302
  if (!this.#pool) throw new PluginError("no database is configured");
166
303
  return this.#pool;
167
304
  },
168
305
  providers,
169
- core: this.#core,
170
306
  dashboard: () => {
171
307
  if (!this.#sessions)
172
308
  throw new NotLinkedError(
@@ -176,53 +312,37 @@ export class Roundtable {
176
312
  },
177
313
  },
178
314
  this.#tiers,
315
+ this.#services,
179
316
  );
180
- const { interactions, routes, channels } = this.#registry;
317
+ const { routes, channels } = this.#registry;
181
318
  this.#sessions = linkSessions(this.#registry);
182
319
  this.#events.link(this.#registry.handlers);
183
320
  this.#router = new ChannelRouter({
184
321
  claims: channels,
322
+ targets: (name) =>
323
+ this.#registry.backgroundTargets.find((t) => t.name === name),
185
324
  queue: this.#queue,
186
- stop: (channel) =>
187
- this.#plugins.some((plugin) => plugin.stopTurn?.(channel) ?? false),
188
325
  logger,
189
326
  ...(this.#options.conversations?.forwardJoinMs === undefined
190
327
  ? {}
191
328
  : { forwardJoinMs: this.#options.conversations.forwardJoinMs }),
192
329
  });
193
- if (interactions.length > 0 && !commands)
194
- throw new PluginError("interactions need a configured root command");
195
- const composed =
196
- commands && interactions.length > 0
197
- ? composeInteractions(commands.root, interactions)
198
- : undefined;
199
- const http = new HttpListeners(listeners, routes);
200
- for (const plugin of this.#plugins) await plugin.preflight?.();
201
- if (composed)
202
- for (const plugin of this.#plugins) plugin.useCommands?.(composed);
203
- for (const service of this.#registry.services) await service.start?.();
330
+ const http = new HttpListeners(listeners, routes, logger);
331
+ for (const plugin of this.#active) await plugin.preflight?.();
332
+ for (const service of this.#registry.services) {
333
+ await service.start?.();
334
+ this.#started.push(service);
335
+ }
204
336
  // Requests arrive only once everything they may reach is running.
205
- http.start();
206
337
  this.#listeners = http;
207
- const agentServer = this.#agentServer();
208
- if (agentServer)
209
- void startAgentServer(agentServer, this.#registry.handlers, logger);
210
- }
211
-
212
- /** The one plugin's way to start the agent server; two plugins that start it are refused. */
213
- #agentServer(): (() => Promise<void>) | undefined {
214
- const starting = this.#plugins.filter((plugin) => plugin.agentServer);
215
- const [first, second] = starting;
216
- if (second)
217
- throw new PluginError(
218
- `plugins ${first?.name} and ${second.name} both start the agent server. Keep one.`,
219
- );
220
- return first?.agentServer?.bind(first);
338
+ http.start();
339
+ // After the listeners, so a background start may rely on everything else running.
340
+ void startInBackground(this.#registry, logger);
221
341
  }
222
342
 
223
343
  /** Opens the pool and runs every plugin's migrations; a failure closes it again and stops the boot. */
224
344
  async #migrate(): Promise<void> {
225
- const migrations = this.#plugins.flatMap(
345
+ const migrations = this.#active.flatMap(
226
346
  (plugin) => plugin.migrations ?? [],
227
347
  );
228
348
  const { database } = this.#options;
@@ -233,12 +353,22 @@ export class Roundtable {
233
353
  }
234
354
  const pool = openPool(database.url);
235
355
  try {
236
- await migrate(pool, migrations);
356
+ const report = await runMigrations(pool, this.#active);
357
+ this.#options.logger.info(
358
+ {
359
+ applied: report.applied,
360
+ skipped: report.skipped.length,
361
+ everyBoot: report.everyBoot.length,
362
+ },
363
+ "migrations",
364
+ );
237
365
  } catch (error) {
238
366
  await pool.close();
239
367
  if (error instanceof MigrationError) {
240
- const owner = this.#plugins.find((plugin) =>
241
- plugin.migrations?.some(({ name }) => name === error.migration),
368
+ const owner = this.#active.find((plugin) =>
369
+ plugin.migrations?.some(
370
+ ({ name }) => `${plugin.name}/${name}` === error.migration,
371
+ ),
242
372
  );
243
373
  throw new PluginError(
244
374
  `plugin ${owner?.name ?? "unknown"}: migration ${error.migration} failed: ${String(error.cause)}. Fix the migration or restore the database, then start again.`,
@@ -250,20 +380,41 @@ export class Roundtable {
250
380
  this.#pool = pool;
251
381
  }
252
382
 
253
- /** Shuts down once idle when the process is asked to stop. */
383
+ /** Shuts down once idle when the process is asked to stop, then exits with the shutdown's code. */
254
384
  listen(): void {
255
- process.once("SIGTERM", () => void this.shutdown("SIGTERM"));
256
- process.once("SIGINT", () => void this.shutdown("SIGINT"));
385
+ const { exit = process.exit } = this.#options;
386
+ let signalled = false;
387
+ for (const signal of ["SIGTERM", "SIGINT"] as const)
388
+ process.once(signal, () => {
389
+ if (signalled) return;
390
+ signalled = true;
391
+ void this.shutdown(signal).then(exit);
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Stops the host once no work runs or waits and returns the exit code: 0, or 1 when the
397
+ * boot had failed or a listener, service or the pool did not stop. Every call shares the one
398
+ * shutdown.
399
+ */
400
+ shutdown(signal: string): Promise<number> {
401
+ this.#stopping ??= this.#stop(signal);
402
+ return this.#stopping;
257
403
  }
258
404
 
259
- async shutdown(signal: string): Promise<void> {
260
- const { logger, aborted, drain, exit = process.exit } = this.#options;
261
- const { services } = this.#registry;
405
+ async #stop(signal: string): Promise<number> {
406
+ const { logger, aborted, drain } = this.#options;
407
+ // A signal during the boot waits for it to settle; a failed boot has already stopped.
408
+ await this.#booting?.catch(() => undefined);
409
+ if (running !== this) return this.#bootFailed ? 1 : 0;
262
410
  // Everything keeps serving until nothing runs or waits, so a deploy never cuts a turn short.
263
411
  logger.info({ signal }, "shutting down once idle");
264
412
  const left = await waitUntilIdle({
265
413
  ...drain,
266
- busy: () => services.flatMap((service) => service.busy?.() ?? []),
414
+ busy: () => [
415
+ ...this.#queue.busy(),
416
+ ...this.#started.flatMap((service) => service.busy?.() ?? []),
417
+ ],
267
418
  });
268
419
  if (left.length > 0) {
269
420
  logger.warn(
@@ -276,22 +427,44 @@ export class Roundtable {
276
427
  }
277
428
  await this.#events.deliver("shutdown", left);
278
429
  logger.info({ signal }, "shutting down");
430
+ const clean = await this.#teardown();
431
+ running = undefined;
432
+ return clean ? 0 : 1;
433
+ }
434
+
435
+ /**
436
+ * Stops what a start or a run left going, in reverse of how it started: the listeners, then
437
+ * the started services, then the pool. Each failure is logged and the rest still stop;
438
+ * returns whether all of them did.
439
+ */
440
+ async #teardown(): Promise<boolean> {
441
+ const { logger } = this.#options;
442
+ let clean = true;
279
443
  // No request may reach a service that has stopped.
280
444
  const closed = await attempt(() => this.#listeners?.stop());
281
- if (!closed.ok)
445
+ if (!closed.ok) {
446
+ clean = false;
282
447
  logger.error({ err: closed.error }, "http listeners did not close");
283
- for (const service of services.toReversed()) {
448
+ }
449
+ for (const service of this.#started.toReversed()) {
284
450
  const stopped = await attempt(() => service.stop?.());
285
- if (!stopped.ok)
451
+ if (!stopped.ok) {
452
+ clean = false;
286
453
  logger.error(
287
454
  { service: service.name, err: stopped.error },
288
455
  "service did not stop",
289
456
  );
457
+ }
290
458
  }
291
459
  // Last, once nothing that queries it runs.
292
460
  const released = await attempt(() => this.#pool?.close());
293
- if (!released.ok)
461
+ if (!released.ok) {
462
+ clean = false;
294
463
  logger.error({ err: released.error }, "database pool did not close");
295
- exit(0);
464
+ }
465
+ this.#listeners = undefined;
466
+ this.#started = [];
467
+ this.#pool = undefined;
468
+ return clean;
296
469
  }
297
470
  }
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, rmSync } from "node:fs";
2
2
  import type { Server } from "bun";
3
3
  import { PluginError } from "../errors.ts";
4
+ import type { Logger } from "../log.ts";
4
5
 
5
6
  /** A handler a plugin attaches to one of the host's configured listeners. */
6
7
  export interface HttpRoute {
@@ -15,7 +16,11 @@ export interface HttpRoute {
15
16
 
16
17
  /** Where a listener serves: a unix socket, reached from outside through a tunnel, or a TCP port. */
17
18
  export type ListenerAddress =
18
- | { socketPath: string }
19
+ | {
20
+ socketPath: string;
21
+ /** The socket file's permission bits; default `0o660`, so only its owner and group connect. */
22
+ mode?: number;
23
+ }
19
24
  | { port: number; hostname?: string };
20
25
 
21
26
  /** An address the host serves HTTP on, with the id routes name it by. */
@@ -62,34 +67,52 @@ function validate(
62
67
  });
63
68
  }
64
69
 
65
- /** Routes one listener's request; a request no route takes is 404. The URL is never logged, since paths may hold tokens. */
66
- export function routeRequest(
70
+ const serverError = () =>
71
+ new Response("Internal Server Error", { status: 500 });
72
+
73
+ /**
74
+ * Routes one listener's request; a request no route takes is 404, and a route that throws, or
75
+ * whose promise rejects, is 500 with a fixed body. The failure is logged with the route's name
76
+ * and listener, never the URL, since paths may hold tokens.
77
+ */
78
+ export async function routeRequest(
67
79
  routes: readonly HttpRoute[],
68
80
  request: Request,
69
- ): Response | Promise<Response> {
81
+ listener: string,
82
+ logger: Logger,
83
+ ): Promise<Response> {
70
84
  // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
71
85
  const path = new URL(request.url).pathname;
72
86
  const route = routes.find(
73
87
  (r) =>
74
88
  matches(r, path) && (!r.methods || r.methods.includes(request.method)),
75
89
  );
76
- return route
77
- ? route.handle(request)
78
- : new Response("Not found", { status: 404 });
90
+ if (!route) return new Response("Not found", { status: 404 });
91
+ try {
92
+ return await route.handle(request);
93
+ } catch (err) {
94
+ logger.error({ err, route: route.name, listener }, "route failed");
95
+ return serverError();
96
+ }
79
97
  }
80
98
 
81
99
  /**
82
100
  * Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
83
101
  * turns and quiet model streams. Bun 1.4.2 honors `idleTimeout` on unix sockets, but its
84
102
  * types reject the option there, hence the cast. A stale socket file is removed first.
85
- * (`shared/unix-server.ts` keeps its own copy for the party worker's image.)
103
+ * (`shared/unix-server.ts` keeps its own copy for standalone worker images.)
86
104
  */
87
105
  function serveUnix(
88
106
  socketPath: string,
89
107
  fetch: (request: Request) => Response | Promise<Response>,
90
108
  ): Server<undefined> {
91
109
  rmSync(socketPath, { force: true });
92
- const options = { unix: socketPath, idleTimeout: 0, fetch };
110
+ const options = {
111
+ unix: socketPath,
112
+ idleTimeout: 0,
113
+ fetch,
114
+ error: serverError,
115
+ };
93
116
  // SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
94
117
  return Bun.serve(
95
118
  options as unknown as Parameters<typeof Bun.serve>[0],
@@ -107,6 +130,7 @@ function serveTcp(
107
130
  ...(hostname ? { hostname } : {}),
108
131
  idleTimeout: 0,
109
132
  fetch,
133
+ error: serverError,
110
134
  });
111
135
  }
112
136
 
@@ -115,31 +139,42 @@ function serveTcp(
115
139
  export class HttpListeners {
116
140
  readonly #listeners: readonly ListenerConfig[];
117
141
  readonly #routes: readonly HttpRoute[];
142
+ readonly #logger: Logger;
118
143
  #servers: Server<undefined>[] = [];
119
144
 
120
145
  /** Validates every route before any socket opens. */
121
146
  constructor(
122
147
  listeners: readonly ListenerConfig[],
123
148
  routes: readonly HttpRoute[],
149
+ logger: Logger,
124
150
  ) {
125
151
  validate(listeners, routes);
152
+ this.#logger = logger;
126
153
  this.#listeners = listeners;
127
154
  this.#routes = routes;
128
155
  }
129
156
 
157
+ /** Opens every listener; when one fails, the ones already open close again before the error is thrown. */
130
158
  start(): void {
131
- for (const listener of this.#listeners) {
132
- const routes = this.#routes.filter(
133
- (route) => route.listener === listener.id,
134
- );
135
- const handle = (request: Request) => routeRequest(routes, request);
136
- if ("socketPath" in listener) {
137
- this.#servers.push(serveUnix(listener.socketPath, handle));
138
- // cloudflared runs as another user in its container.
139
- chmodSync(listener.socketPath, 0o666);
140
- } else {
141
- this.#servers.push(serveTcp(listener.port, listener.hostname, handle));
159
+ try {
160
+ for (const listener of this.#listeners) {
161
+ const routes = this.#routes.filter(
162
+ (route) => route.listener === listener.id,
163
+ );
164
+ const handle = (request: Request) =>
165
+ routeRequest(routes, request, listener.id, this.#logger);
166
+ if ("socketPath" in listener) {
167
+ this.#servers.push(serveUnix(listener.socketPath, handle));
168
+ chmodSync(listener.socketPath, listener.mode ?? 0o660);
169
+ } else {
170
+ this.#servers.push(
171
+ serveTcp(listener.port, listener.hostname, handle),
172
+ );
173
+ }
142
174
  }
175
+ } catch (error) {
176
+ this.stop();
177
+ throw error;
143
178
  }
144
179
  }
145
180
 
@@ -21,6 +21,8 @@ export function agentPanelEn(ctx: CatalogContext) {
21
21
  }) =>
22
22
  `### ${v.displayName}\n-# agent name \`${v.name}\`, cannot be changed\n**Model** · \`${v.model}\` · thinking \`${v.thinking}\`${v.follows}\n${v.skills}\n-# To add or remove a skill, ask any agent to use agent_skills.`,
23
23
  agentAvatarPromptText: (prompt: string) => `**Avatar prompt**\n${prompt}`,
24
+ agentNoImageProvider:
25
+ "**Avatar** · No image provider is configured, so this avatar is generated from the agent's display name and cannot be redrawn or edited. A plugin that fills the `images` slot adds drawing.",
24
26
  agentPromptInline: (prompt: string) =>
25
27
  `**Prompt**\n\`\`\`\n${prompt}\n\`\`\``,
26
28
  agentPromptFile: (length: number) =>
@@ -69,6 +71,8 @@ export function agentPanelZhTW(
69
71
  }) =>
70
72
  `### ${v.displayName}\n-# agent 名稱 \`${v.name}\`,不能更改\n**模型** \`${v.model}\` thinking \`${v.thinking}\`${v.follows}\n${v.skills}\n-# 要增減 skill,請任一個 agent 用 agent_skills。`,
71
73
  agentAvatarPromptText: (prompt: string) => `**頭像提示詞**\n${prompt}`,
74
+ agentNoImageProvider:
75
+ "**頭像** 尚未設定圖像提供者,所以這個頭像由 agent 的顯示名自動產生,不能重畫或修改。要能畫頭像,需要一個填入 `images` 槽位的外掛。",
72
76
  agentPromptInline: (prompt: string) =>
73
77
  `**提示詞**\n\`\`\`\n${prompt}\n\`\`\``,
74
78
  agentPromptFile: (length: number) =>