pi-roundtable 0.1.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 (315) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/docs/plugins.md +924 -0
  5. package/examples/channels.test.ts +13 -0
  6. package/examples/channels.ts +27 -0
  7. package/examples/dashboard.test.ts +11 -0
  8. package/examples/dashboard.ts +9 -0
  9. package/examples/events.test.ts +26 -0
  10. package/examples/events.ts +25 -0
  11. package/examples/guide.test.ts +52 -0
  12. package/examples/holds.test.ts +16 -0
  13. package/examples/holds.ts +30 -0
  14. package/examples/http.test.ts +12 -0
  15. package/examples/http.ts +17 -0
  16. package/examples/interactions.test.ts +25 -0
  17. package/examples/interactions.ts +28 -0
  18. package/examples/migrations.test.ts +31 -0
  19. package/examples/migrations.ts +42 -0
  20. package/examples/packages.test.ts +9 -0
  21. package/examples/packages.ts +9 -0
  22. package/examples/preflight.test.ts +11 -0
  23. package/examples/preflight.ts +19 -0
  24. package/examples/prompt.test.ts +25 -0
  25. package/examples/prompt.ts +20 -0
  26. package/examples/providers.test.ts +10 -0
  27. package/examples/providers.ts +17 -0
  28. package/examples/seeds.test.ts +12 -0
  29. package/examples/seeds.ts +18 -0
  30. package/examples/selection.test.ts +13 -0
  31. package/examples/selection.ts +14 -0
  32. package/examples/services.test.ts +20 -0
  33. package/examples/services.ts +30 -0
  34. package/examples/session-tools.test.ts +24 -0
  35. package/examples/session-tools.ts +34 -0
  36. package/examples/tools.test.ts +18 -0
  37. package/examples/tools.ts +28 -0
  38. package/package.json +55 -0
  39. package/src/cli/add-plugin.test.ts +107 -0
  40. package/src/cli/add-plugin.ts +75 -0
  41. package/src/cli/checks/basic.test.ts +276 -0
  42. package/src/cli/checks/bun.ts +26 -0
  43. package/src/cli/checks/configuration.ts +28 -0
  44. package/src/cli/checks/database.test.ts +117 -0
  45. package/src/cli/checks/database.ts +100 -0
  46. package/src/cli/checks/discord.test.ts +200 -0
  47. package/src/cli/checks/discord.ts +218 -0
  48. package/src/cli/checks/environment.ts +27 -0
  49. package/src/cli/checks/model.ts +28 -0
  50. package/src/cli/checks/public-url.ts +40 -0
  51. package/src/cli/cli.test.ts +122 -0
  52. package/src/cli/cli.ts +166 -0
  53. package/src/cli/config-edit.test.ts +130 -0
  54. package/src/cli/config-edit.ts +199 -0
  55. package/src/cli/discord-api.ts +96 -0
  56. package/src/cli/doctor.test.ts +184 -0
  57. package/src/cli/doctor.ts +92 -0
  58. package/src/cli/http.ts +23 -0
  59. package/src/cli/init.test.ts +127 -0
  60. package/src/cli/init.ts +82 -0
  61. package/src/cli/main.ts +13 -0
  62. package/src/cli/project.ts +123 -0
  63. package/src/cli/report.ts +68 -0
  64. package/src/cli/roundtable.mjs +22 -0
  65. package/src/cli/runtime.ts +53 -0
  66. package/src/cli/size.test.ts +9 -0
  67. package/src/cli/start.ts +33 -0
  68. package/src/cli/templates.test.ts +129 -0
  69. package/src/cli/templates.ts +119 -0
  70. package/src/cli/testing/fixtures.ts +106 -0
  71. package/src/core/agents/agent-claim.test.ts +142 -0
  72. package/src/core/agents/agent-claim.ts +170 -0
  73. package/src/core/agents/agent-dashboard.test.ts +120 -0
  74. package/src/core/agents/agent-dashboard.ts +185 -0
  75. package/src/core/agents/agent-guild.test.ts +124 -0
  76. package/src/core/agents/agent-messages.ts +189 -0
  77. package/src/core/agents/agent-ports.ts +103 -0
  78. package/src/core/agents/agent-prompt.ts +163 -0
  79. package/src/core/agents/agent-rules.ts +106 -0
  80. package/src/core/agents/agent-schema.ts +115 -0
  81. package/src/core/agents/agent-settings.ts +49 -0
  82. package/src/core/agents/agent-store.test.ts +205 -0
  83. package/src/core/agents/agent-store.ts +384 -0
  84. package/src/core/agents/agent-team.ts +445 -0
  85. package/src/core/agents/agent-tools.ts +386 -0
  86. package/src/core/agents/avatar-studio.test.ts +88 -0
  87. package/src/core/agents/avatar-studio.ts +147 -0
  88. package/src/core/agents/group-messages.ts +99 -0
  89. package/src/core/agents/group-round.test.ts +143 -0
  90. package/src/core/agents/group-round.ts +117 -0
  91. package/src/core/agents/group-turns.ts +132 -0
  92. package/src/core/agents/owner-identity.test.ts +172 -0
  93. package/src/core/agents/team-editing.ts +205 -0
  94. package/src/core/agents/team-keys.ts +40 -0
  95. package/src/core/agents/team-layout.ts +135 -0
  96. package/src/core/agents/team-lifecycle.ts +188 -0
  97. package/src/core/agents/team-options.ts +52 -0
  98. package/src/core/agents/team-status.ts +80 -0
  99. package/src/core/agents/team-text.ts +60 -0
  100. package/src/core/agents/team-turn-types.ts +107 -0
  101. package/src/core/agents/team-turns.test.ts +278 -0
  102. package/src/core/agents/team-turns.ts +323 -0
  103. package/src/core/assets/neutral.png +0 -0
  104. package/src/core/assets/prompts/shared-guest.md +11 -0
  105. package/src/core/assets/prompts/shared.md +11 -0
  106. package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
  107. package/src/core/attachments/attachment-dir.ts +15 -0
  108. package/src/core/attachments/attachment-fetcher.ts +83 -0
  109. package/src/core/attachments/attachments.test.ts +85 -0
  110. package/src/core/attachments/image-prep.ts +50 -0
  111. package/src/core/attachments/prompt-block.ts +37 -0
  112. package/src/core/attachments/turn-attachments.ts +79 -0
  113. package/src/core/boundary.test.ts +45 -0
  114. package/src/core/builtin/agent-server.ts +275 -0
  115. package/src/core/builtin/discord.ts +95 -0
  116. package/src/core/builtin/modules.test.ts +127 -0
  117. package/src/core/builtin/modules.ts +176 -0
  118. package/src/core/builtin/seeds.ts +10 -0
  119. package/src/core/builtin/stores.ts +42 -0
  120. package/src/core/config/config.test.ts +135 -0
  121. package/src/core/config/config.ts +285 -0
  122. package/src/core/config/schema.ts +181 -0
  123. package/src/core/contract/channels.ts +152 -0
  124. package/src/core/contract/discord.ts +33 -0
  125. package/src/core/contract/providers.ts +67 -0
  126. package/src/core/db/guild-scope.ts +53 -0
  127. package/src/core/db/migrations.test.ts +237 -0
  128. package/src/core/db/migrations.ts +37 -0
  129. package/src/core/define-roundtable.test.ts +134 -0
  130. package/src/core/define-roundtable.ts +195 -0
  131. package/src/core/define.test.ts +144 -0
  132. package/src/core/define.ts +154 -0
  133. package/src/core/discord/agent-commands.test.ts +58 -0
  134. package/src/core/discord/agent-commands.ts +385 -0
  135. package/src/core/discord/agent-discord.test.ts +70 -0
  136. package/src/core/discord/agent-discord.ts +367 -0
  137. package/src/core/discord/channel-executor.ts +404 -0
  138. package/src/core/discord/channel-operations.ts +329 -0
  139. package/src/core/discord/discord-surface.ts +431 -0
  140. package/src/core/discord/dispatch-thread-host.test.ts +114 -0
  141. package/src/core/discord/dispatch-thread-host.ts +72 -0
  142. package/src/core/discord/dispatch-threads.test.ts +94 -0
  143. package/src/core/discord/dispatch-threads.ts +180 -0
  144. package/src/core/discord/interaction-module.ts +1 -0
  145. package/src/core/discord/owner-cards.test.ts +435 -0
  146. package/src/core/discord/owner-cards.ts +463 -0
  147. package/src/core/discord/owner-command.ts +126 -0
  148. package/src/core/discord/owner-discord-access.ts +183 -0
  149. package/src/core/discord/owner-discord-threads.ts +160 -0
  150. package/src/core/discord/owner-discord.test.ts +325 -0
  151. package/src/core/discord/owner-discord.ts +282 -0
  152. package/src/core/discord/owner-panel.ts +103 -0
  153. package/src/core/discord/schedule-commands.ts +149 -0
  154. package/src/core/discord/stop-button.ts +20 -0
  155. package/src/core/domain/attachment.ts +39 -0
  156. package/src/core/domain/conversation.ts +42 -0
  157. package/src/core/domain/errors.ts +31 -0
  158. package/src/core/domain/expression.ts +21 -0
  159. package/src/core/domain/owner-prompts.ts +45 -0
  160. package/src/core/domain/ports.ts +91 -0
  161. package/src/core/domain/profile.ts +32 -0
  162. package/src/core/drain.test.ts +46 -0
  163. package/src/core/drain.ts +33 -0
  164. package/src/core/errors.ts +29 -0
  165. package/src/core/events.test.ts +81 -0
  166. package/src/core/events.ts +55 -0
  167. package/src/core/holds.test.ts +54 -0
  168. package/src/core/holds.ts +46 -0
  169. package/src/core/host.test.ts +536 -0
  170. package/src/core/host.ts +297 -0
  171. package/src/core/http/listeners.test.ts +164 -0
  172. package/src/core/http/listeners.ts +151 -0
  173. package/src/core/i18n/agent-panel.ts +96 -0
  174. package/src/core/i18n/cards.ts +64 -0
  175. package/src/core/i18n/channels.ts +28 -0
  176. package/src/core/i18n/conversation.ts +49 -0
  177. package/src/core/i18n/dashboard.ts +72 -0
  178. package/src/core/i18n/discord.ts +23 -0
  179. package/src/core/i18n/en.ts +27 -0
  180. package/src/core/i18n/i18n.test.ts +139 -0
  181. package/src/core/i18n/index.ts +58 -0
  182. package/src/core/i18n/owner.ts +35 -0
  183. package/src/core/i18n/schedules.ts +92 -0
  184. package/src/core/i18n/time-zones.ts +27 -0
  185. package/src/core/i18n/types.ts +7 -0
  186. package/src/core/i18n/zh-tw.ts +26 -0
  187. package/src/core/identity.test.ts +18 -0
  188. package/src/core/identity.ts +31 -0
  189. package/src/core/judging/confirmation-judge.ts +68 -0
  190. package/src/core/judging/effort-judge.test.ts +112 -0
  191. package/src/core/judging/effort-judge.ts +158 -0
  192. package/src/core/judging/model-judge.test.ts +126 -0
  193. package/src/core/judging/model-judge.ts +193 -0
  194. package/src/core/log.ts +45 -0
  195. package/src/core/models.ts +52 -0
  196. package/src/core/modules/background/background-turns.ts +78 -0
  197. package/src/core/modules/delegation/delegate.ts +54 -0
  198. package/src/core/modules/delegation/delegator.test.ts +170 -0
  199. package/src/core/modules/delegation/delegator.ts +191 -0
  200. package/src/core/modules/delegation/sol-worker.ts +78 -0
  201. package/src/core/modules/discord-admin/discord-admin.ts +288 -0
  202. package/src/core/modules/host-shell/shell-policy.ts +386 -0
  203. package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
  204. package/src/core/modules/memory/owner-memory-store.ts +228 -0
  205. package/src/core/modules/memory/owner-memory.ts +153 -0
  206. package/src/core/modules/notify/notify.ts +26 -0
  207. package/src/core/modules/schedules/recurrence.ts +206 -0
  208. package/src/core/modules/schedules/schedule-store.ts +213 -0
  209. package/src/core/modules/schedules/schedule-tools.ts +196 -0
  210. package/src/core/modules/schedules/schedule.test.ts +358 -0
  211. package/src/core/modules/schedules/scheduler.ts +121 -0
  212. package/src/core/modules/schedules/schedules.ts +87 -0
  213. package/src/core/modules/skills/repo-name.ts +11 -0
  214. package/src/core/modules/skills/skill-kind.test.ts +55 -0
  215. package/src/core/modules/skills/skill-link.ts +69 -0
  216. package/src/core/modules/skills/skill-listing.ts +128 -0
  217. package/src/core/modules/skills/skill-registry.test.ts +376 -0
  218. package/src/core/modules/skills/skill-registry.ts +323 -0
  219. package/src/core/modules/skills/skill-rules.ts +120 -0
  220. package/src/core/modules/skills/skill-store.ts +285 -0
  221. package/src/core/modules/skills/skill-tools.ts +207 -0
  222. package/src/core/ops/error-reporter.test.ts +283 -0
  223. package/src/core/ops/error-reporter.ts +271 -0
  224. package/src/core/plugin.ts +200 -0
  225. package/src/core/presentation/card-cadence.ts +41 -0
  226. package/src/core/presentation/headline.ts +34 -0
  227. package/src/core/presentation/presentation.test.ts +166 -0
  228. package/src/core/presentation/quiet-links.ts +36 -0
  229. package/src/core/presentation/reply-splitter.ts +110 -0
  230. package/src/core/presentation/thinking-line.ts +24 -0
  231. package/src/core/public-entry.test.ts +59 -0
  232. package/src/core/registry/contributions.test.ts +215 -0
  233. package/src/core/registry/contributions.ts +249 -0
  234. package/src/core/registry/interactions.test.ts +87 -0
  235. package/src/core/registry/interactions.ts +55 -0
  236. package/src/core/registry/providers.test.ts +103 -0
  237. package/src/core/registry/providers.ts +55 -0
  238. package/src/core/routing/channel-queue.test.ts +31 -0
  239. package/src/core/routing/channel-queue.ts +44 -0
  240. package/src/core/routing/channel-router.test.ts +326 -0
  241. package/src/core/routing/channel-router.ts +157 -0
  242. package/src/core/routing/conversation-kind.ts +14 -0
  243. package/src/core/routing/forward-join.ts +65 -0
  244. package/src/core/routing/message-text.ts +16 -0
  245. package/src/core/routing/settle-turn.test.ts +21 -0
  246. package/src/core/routing/settle-turn.ts +26 -0
  247. package/src/core/runtime/compaction-tiers.test.ts +227 -0
  248. package/src/core/runtime/compaction-tiers.ts +203 -0
  249. package/src/core/runtime/conversation-sessions.ts +162 -0
  250. package/src/core/runtime/extensions/agent-prompt.ts +16 -0
  251. package/src/core/runtime/extensions/ask-user.test.ts +114 -0
  252. package/src/core/runtime/extensions/ask-user.ts +98 -0
  253. package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
  254. package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
  255. package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
  256. package/src/core/runtime/mcp.ts +23 -0
  257. package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
  258. package/src/core/runtime/pending-confirmation-store.ts +75 -0
  259. package/src/core/runtime/pi-agent-runtime.ts +477 -0
  260. package/src/core/runtime/prompt-slot.ts +81 -0
  261. package/src/core/runtime/runtime-types.ts +150 -0
  262. package/src/core/runtime/session-archive.test.ts +20 -0
  263. package/src/core/runtime/session-archive.ts +17 -0
  264. package/src/core/runtime/session-factory.ts +291 -0
  265. package/src/core/runtime/steerable-run.test.ts +264 -0
  266. package/src/core/runtime/steerable-run.ts +109 -0
  267. package/src/core/runtime/text-tools.test.ts +110 -0
  268. package/src/core/runtime/text-tools.ts +66 -0
  269. package/src/core/runtime/turn-answer.test.ts +72 -0
  270. package/src/core/runtime/turn-answer.ts +79 -0
  271. package/src/core/runtime/worker-task.test.ts +82 -0
  272. package/src/core/runtime/worker-task.ts +65 -0
  273. package/src/core/services.test.ts +28 -0
  274. package/src/core/services.ts +107 -0
  275. package/src/core/sessions.test.ts +85 -0
  276. package/src/core/sessions.ts +149 -0
  277. package/src/core/shared/attachment-reader.ts +91 -0
  278. package/src/core/shared/delegate-tool.ts +15 -0
  279. package/src/core/shared/mcp-adapter.ts +58 -0
  280. package/src/core/shared/package-dir.ts +9 -0
  281. package/src/core/shared/profile-tools.ts +16 -0
  282. package/src/core/shared/read-attachment-tool.ts +43 -0
  283. package/src/core/shared/schedule-tools.ts +118 -0
  284. package/src/core/shared/session-messages.ts +50 -0
  285. package/src/core/shared/tool-result.ts +9 -0
  286. package/src/core/shared/unix-server.ts +18 -0
  287. package/src/core/size.test.ts +9 -0
  288. package/src/core/speakers.test.ts +78 -0
  289. package/src/core/speakers.ts +143 -0
  290. package/src/core/testing/database.ts +52 -0
  291. package/src/core/testing/file-size.ts +34 -0
  292. package/src/core/testing/locale.ts +8 -0
  293. package/src/core/testing/modules.ts +97 -0
  294. package/src/core/testing/owner.ts +15 -0
  295. package/src/core/testing/thread-host.ts +60 -0
  296. package/src/core/time.test.ts +166 -0
  297. package/src/core/time.ts +67 -0
  298. package/src/core/tool-tiers.test.ts +117 -0
  299. package/src/core/tool-tiers.ts +127 -0
  300. package/src/entries.test.ts +112 -0
  301. package/src/index.ts +28 -0
  302. package/src/testing.test.ts +156 -0
  303. package/src/testing.ts +182 -0
  304. package/templates/.env.example +22 -0
  305. package/templates/README.md +11 -0
  306. package/templates/_gitignore +3 -0
  307. package/templates/agents.ts +12 -0
  308. package/templates/biome.json.tmpl +7 -0
  309. package/templates/docker-compose.yml +16 -0
  310. package/templates/package.json.tmpl +24 -0
  311. package/templates/persona/shared.md +2 -0
  312. package/templates/plugin/plugin.test.ts.tmpl +13 -0
  313. package/templates/plugin/plugin.ts +18 -0
  314. package/templates/roundtable.config.ts +24 -0
  315. package/templates/tsconfig.json.tmpl +16 -0
@@ -0,0 +1,297 @@
1
+ import type { SQL } from "bun";
2
+ import type { ConversationPort } from "./contract/channels.ts";
3
+ import type { CommandRoot } from "./contract/discord.ts";
4
+ import { migrate, openPool } from "./db/migrations.ts";
5
+ import { type DrainOptions, waitUntilIdle } from "./drain.ts";
6
+ import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
7
+ import { EventBus } from "./events.ts";
8
+ import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
9
+ import type { JudgeModel } from "./judging/model-judge.ts";
10
+ import type { Logger } from "./log.ts";
11
+ import type {
12
+ AgentServerOutcome,
13
+ LinkedSessions,
14
+ RoundtablePlugin,
15
+ } from "./plugin.ts";
16
+ import {
17
+ collectContributions,
18
+ emptyRegistry,
19
+ linkSessions,
20
+ type Registry,
21
+ } from "./registry/contributions.ts";
22
+ import { composeInteractions } from "./registry/interactions.ts";
23
+ import { resolveProviders } from "./registry/providers.ts";
24
+ import { ChannelQueue } from "./routing/channel-queue.ts";
25
+ import { ChannelRouter } from "./routing/channel-router.ts";
26
+ import { CoreRegistry } from "./services.ts";
27
+ import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
28
+
29
+ export interface RoundtableOptions {
30
+ 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
+ };
38
+ /** The HTTP listeners plugins attach routes to. */
39
+ listeners?: readonly ListenerConfig[];
40
+ /** How long a bare forward waits for the message it follows. */
41
+ conversations?: {
42
+ forwardJoinMs?: number;
43
+ };
44
+ /** The model the default judge asks, when no plugin provides a judge. */
45
+ judgeModel?: JudgeModel;
46
+ /** What each tool needs: the operator's settings, with the plugins' tools added when the host links. */
47
+ toolTiers?: ToolTierTable;
48
+ /** The database the plugins' migrations and stores use; the host owns its one pool. */
49
+ database?: { url: string };
50
+ /** Receives the work a shutdown drain gave up on, before any service stops. */
51
+ aborted?: (left: string[]) => Promise<void>;
52
+ /** The drain's limit and clock; tests shorten them. */
53
+ drain?: Omit<DrainOptions, "busy">;
54
+ exit?: (code: number) => void;
55
+ }
56
+
57
+ type Attempt = { ok: true } | { ok: false; error: unknown };
58
+
59
+ /** Runs one step whose failure the caller reports and moves past. */
60
+ async function attempt(step: () => Promise<void> | void): Promise<Attempt> {
61
+ try {
62
+ await step();
63
+ return { ok: true };
64
+ } catch (error) {
65
+ return { ok: false, error };
66
+ }
67
+ }
68
+
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"],
73
+ logger: Logger,
74
+ ): 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
+ }
88
+ }
89
+
90
+ /**
91
+ * 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
94
+ * every setup, link, and the preflight succeeded. On shutdown it waits until no work runs or
95
+ * waits before closing the listeners and stopping the services in reverse.
96
+ */
97
+ // pi-lens-ignore: large-class
98
+ export class Roundtable {
99
+ readonly #options: RoundtableOptions;
100
+ readonly #plugins: readonly RoundtablePlugin[];
101
+ #registry: Registry = emptyRegistry();
102
+ #sessions: LinkedSessions | undefined;
103
+ #router: ChannelRouter | undefined;
104
+ #listeners: HttpListeners | undefined;
105
+ #pool: SQL | undefined;
106
+ readonly #queue = new ChannelQueue();
107
+ readonly #tiers: ToolTierTable;
108
+ readonly #events: EventBus;
109
+ readonly #core = new CoreRegistry();
110
+
111
+ constructor(
112
+ options: RoundtableOptions,
113
+ plugins: readonly RoundtablePlugin[],
114
+ ) {
115
+ this.#options = options;
116
+ this.#plugins = plugins;
117
+ this.#tiers = options.toolTiers ?? toolTiers();
118
+ this.#events = new EventBus(options.logger);
119
+ }
120
+
121
+ /** The claimed channels' conversations; every call before linking throws NotLinkedError. */
122
+ #conversations(): ConversationPort {
123
+ const router = () => {
124
+ if (!this.#router)
125
+ throw new NotLinkedError(
126
+ "conversations are linked once every plugin is set up. Use them from a service's start or from a handler, not during setup.",
127
+ );
128
+ return this.#router;
129
+ };
130
+ return {
131
+ handle: (message) => router().handle(message),
132
+ background: (turn) => router().background(turn),
133
+ startFresh: (channel) => router().startFresh(channel),
134
+ deleteConversation: (channel) => router().deleteConversation(channel),
135
+ stop: (channel) => router().stop(channel),
136
+ postsInPlace: (channel) => router().postsInPlace(channel),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Sets up every plugin, links what they add and runs the preflight, refusing any clash before
142
+ * anything starts, then starts the services and opens the listeners; the agent server starts
143
+ * in the background.
144
+ */
145
+ async run(): Promise<void> {
146
+ const { logger, commands, listeners = [] } = this.#options;
147
+ const providers = resolveProviders(this.#plugins, this.#options.judgeModel);
148
+ await this.#migrate();
149
+ this.#registry = await collectContributions(
150
+ this.#plugins,
151
+ {
152
+ logger,
153
+ sessions: () => {
154
+ if (!this.#sessions)
155
+ throw new NotLinkedError(
156
+ "session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup.",
157
+ );
158
+ return this.#sessions;
159
+ },
160
+ queue: this.#queue,
161
+ toolTiers: this.#tiers,
162
+ events: this.#events.sink,
163
+ conversations: this.#conversations(),
164
+ database: () => {
165
+ if (!this.#pool) throw new PluginError("no database is configured");
166
+ return this.#pool;
167
+ },
168
+ providers,
169
+ core: this.#core,
170
+ dashboard: () => {
171
+ if (!this.#sessions)
172
+ throw new NotLinkedError(
173
+ "dashboard lines are linked once every plugin is set up. Call dashboard() from a service's start or from a handler, not during setup.",
174
+ );
175
+ return this.#registry.dashboard;
176
+ },
177
+ },
178
+ this.#tiers,
179
+ );
180
+ const { interactions, routes, channels } = this.#registry;
181
+ this.#sessions = linkSessions(this.#registry);
182
+ this.#events.link(this.#registry.handlers);
183
+ this.#router = new ChannelRouter({
184
+ claims: channels,
185
+ queue: this.#queue,
186
+ stop: (channel) =>
187
+ this.#plugins.some((plugin) => plugin.stopTurn?.(channel) ?? false),
188
+ logger,
189
+ ...(this.#options.conversations?.forwardJoinMs === undefined
190
+ ? {}
191
+ : { forwardJoinMs: this.#options.conversations.forwardJoinMs }),
192
+ });
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?.();
204
+ // Requests arrive only once everything they may reach is running.
205
+ http.start();
206
+ 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);
221
+ }
222
+
223
+ /** Opens the pool and runs every plugin's migrations; a failure closes it again and stops the boot. */
224
+ async #migrate(): Promise<void> {
225
+ const migrations = this.#plugins.flatMap(
226
+ (plugin) => plugin.migrations ?? [],
227
+ );
228
+ const { database } = this.#options;
229
+ if (!database) {
230
+ if (migrations.length > 0)
231
+ throw new PluginError("migrations need a configured database");
232
+ return;
233
+ }
234
+ const pool = openPool(database.url);
235
+ try {
236
+ await migrate(pool, migrations);
237
+ } catch (error) {
238
+ await pool.close();
239
+ if (error instanceof MigrationError) {
240
+ const owner = this.#plugins.find((plugin) =>
241
+ plugin.migrations?.some(({ name }) => name === error.migration),
242
+ );
243
+ throw new PluginError(
244
+ `plugin ${owner?.name ?? "unknown"}: migration ${error.migration} failed: ${String(error.cause)}. Fix the migration or restore the database, then start again.`,
245
+ { cause: error },
246
+ );
247
+ }
248
+ throw error;
249
+ }
250
+ this.#pool = pool;
251
+ }
252
+
253
+ /** Shuts down once idle when the process is asked to stop. */
254
+ listen(): void {
255
+ process.once("SIGTERM", () => void this.shutdown("SIGTERM"));
256
+ process.once("SIGINT", () => void this.shutdown("SIGINT"));
257
+ }
258
+
259
+ async shutdown(signal: string): Promise<void> {
260
+ const { logger, aborted, drain, exit = process.exit } = this.#options;
261
+ const { services } = this.#registry;
262
+ // Everything keeps serving until nothing runs or waits, so a deploy never cuts a turn short.
263
+ logger.info({ signal }, "shutting down once idle");
264
+ const left = await waitUntilIdle({
265
+ ...drain,
266
+ busy: () => services.flatMap((service) => service.busy?.() ?? []),
267
+ });
268
+ if (left.length > 0) {
269
+ logger.warn(
270
+ { aborted: left },
271
+ "still busy after the drain limit; aborting",
272
+ );
273
+ const recorded = await attempt(() => aborted?.(left));
274
+ if (!recorded.ok)
275
+ logger.error({ err: recorded.error }, "aborted work not recorded");
276
+ }
277
+ await this.#events.deliver("shutdown", left);
278
+ logger.info({ signal }, "shutting down");
279
+ // No request may reach a service that has stopped.
280
+ const closed = await attempt(() => this.#listeners?.stop());
281
+ if (!closed.ok)
282
+ logger.error({ err: closed.error }, "http listeners did not close");
283
+ for (const service of services.toReversed()) {
284
+ const stopped = await attempt(() => service.stop?.());
285
+ if (!stopped.ok)
286
+ logger.error(
287
+ { service: service.name, err: stopped.error },
288
+ "service did not stop",
289
+ );
290
+ }
291
+ // Last, once nothing that queries it runs.
292
+ const released = await attempt(() => this.#pool?.close());
293
+ if (!released.ok)
294
+ logger.error({ err: released.error }, "database pool did not close");
295
+ exit(0);
296
+ }
297
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { PluginError } from "../errors.ts";
6
+ import { HttpListeners, type HttpRoute, routeRequest } from "./listeners.ts";
7
+
8
+ const answer =
9
+ (text: string, listener = "public", methods?: string[]) =>
10
+ (path: HttpRoute["path"]): HttpRoute => ({
11
+ name: `${text} ${JSON.stringify(path)}`,
12
+ listener,
13
+ path,
14
+ ...(methods ? { methods } : {}),
15
+ handle: () => new Response(text),
16
+ });
17
+
18
+ async function reply(
19
+ routes: HttpRoute[],
20
+ path: string,
21
+ method = "GET",
22
+ ): Promise<[number, string]> {
23
+ const response = await routeRequest(
24
+ routes,
25
+ new Request(`http://localhost${path}`, { method }),
26
+ );
27
+ return [response.status, await response.text()];
28
+ }
29
+
30
+ function refused(build: () => unknown): unknown {
31
+ try {
32
+ build();
33
+ return undefined;
34
+ } catch (error) {
35
+ return error;
36
+ }
37
+ }
38
+
39
+ describe("routeRequest", () => {
40
+ const routes = [
41
+ answer("personal")({ exact: "/mcp/personal" }),
42
+ answer("discord")({ prefix: "/mcp/discord/" }),
43
+ answer("avatar", "public", ["GET", "HEAD"])({ prefix: "/avatars/" }),
44
+ ];
45
+
46
+ test("takes exact paths, prefixes, and listed methods only", async () => {
47
+ expect(await reply(routes, "/mcp/personal", "POST")).toEqual([
48
+ 200,
49
+ "personal",
50
+ ]);
51
+ expect(await reply(routes, "/mcp/personal", "DELETE")).toEqual([
52
+ 200,
53
+ "personal",
54
+ ]);
55
+ expect(await reply(routes, "/mcp/discord/abc", "POST")).toEqual([
56
+ 200,
57
+ "discord",
58
+ ]);
59
+ expect(await reply(routes, "/avatars/a.png")).toEqual([200, "avatar"]);
60
+ expect(await reply(routes, "/avatars/a.png", "POST")).toEqual([
61
+ 404,
62
+ "Not found",
63
+ ]);
64
+ expect(await reply(routes, "/mcp/personal/more", "POST")).toEqual([
65
+ 404,
66
+ "Not found",
67
+ ]);
68
+ expect(await reply(routes, "/app/")).toEqual([404, "Not found"]);
69
+ });
70
+ });
71
+
72
+ describe("HttpListeners", () => {
73
+ test("refuses routes one request could reach twice, and unknown listeners", () => {
74
+ const listeners = [
75
+ { id: "public", socketPath: "/tmp/p.sock" },
76
+ { id: "web", socketPath: "/tmp/w.sock" },
77
+ ];
78
+ const build = (routes: HttpRoute[]) => () =>
79
+ new HttpListeners(listeners, routes);
80
+ expect(
81
+ refused(
82
+ build([
83
+ answer("a")({ prefix: "/mcp/" }),
84
+ answer("b")({ exact: "/mcp/personal" }),
85
+ ]),
86
+ ),
87
+ ).toBeInstanceOf(PluginError);
88
+ expect(
89
+ refused(
90
+ build([
91
+ answer("a")({ prefix: "/app/" }),
92
+ answer("b")({ prefix: "/app/api/" }),
93
+ ]),
94
+ ),
95
+ ).toBeInstanceOf(PluginError);
96
+ expect(
97
+ refused(build([answer("a", "elsewhere")({ exact: "/" })])),
98
+ ).toBeInstanceOf(PluginError);
99
+ // Different listeners or methods never clash.
100
+ expect(
101
+ refused(
102
+ build([
103
+ answer("a")({ prefix: "/app/" }),
104
+ answer("b", "web")({ prefix: "/app/" }),
105
+ answer("c", "public", ["GET"])({ exact: "/x" }),
106
+ answer("d", "public", ["POST"])({ exact: "/x" }),
107
+ ]),
108
+ ),
109
+ ).toBeUndefined();
110
+ });
111
+
112
+ test("each socket serves only its own routes, open to cloudflared", async () => {
113
+ const dir = mkdtempSync(join(tmpdir(), "listeners-"));
114
+ const listeners = [
115
+ { id: "public", socketPath: join(dir, "public.sock") },
116
+ { id: "web", socketPath: join(dir, "web.sock") },
117
+ ];
118
+ const http = new HttpListeners(listeners, [
119
+ answer("mcp")({ exact: "/mcp/personal" }),
120
+ answer("app", "web")({ prefix: "/app/" }),
121
+ ]);
122
+ http.start();
123
+ const get = async (socket: string, path: string) => {
124
+ const response = await fetch(`http://localhost${path}`, {
125
+ unix: join(dir, socket),
126
+ });
127
+ return [response.status, await response.text()];
128
+ };
129
+ try {
130
+ expect(await get("public.sock", "/mcp/personal")).toEqual([200, "mcp"]);
131
+ expect(await get("public.sock", "/app/")).toEqual([404, "Not found"]);
132
+ expect(await get("web.sock", "/app/")).toEqual([200, "app"]);
133
+ expect(await get("web.sock", "/mcp/personal")).toEqual([
134
+ 404,
135
+ "Not found",
136
+ ]);
137
+ expect(statSync(join(dir, "web.sock")).mode & 0o777).toBe(0o666);
138
+ } finally {
139
+ http.stop();
140
+ }
141
+ });
142
+
143
+ test("serves a TCP listener with only its own routes", async () => {
144
+ // A free port, found by opening one and closing it again.
145
+ const probe = Bun.serve({ port: 0, fetch: () => new Response("") });
146
+ const port = probe.port ?? 0;
147
+ await probe.stop(true);
148
+ const http = new HttpListeners(
149
+ [{ id: "public", port, hostname: "127.0.0.1" }],
150
+ [answer("avatar")({ prefix: "/avatars/" })],
151
+ );
152
+ http.start();
153
+ try {
154
+ const get = async (path: string) => {
155
+ const response = await fetch(`http://127.0.0.1:${port}${path}`);
156
+ return [response.status, await response.text()];
157
+ };
158
+ expect(await get("/avatars/a.png")).toEqual([200, "avatar"]);
159
+ expect(await get("/elsewhere")).toEqual([404, "Not found"]);
160
+ } finally {
161
+ http.stop();
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,151 @@
1
+ import { chmodSync, rmSync } from "node:fs";
2
+ import type { Server } from "bun";
3
+ import { PluginError } from "../errors.ts";
4
+
5
+ /** A handler a plugin attaches to one of the host's configured listeners. */
6
+ export interface HttpRoute {
7
+ name: string;
8
+ /** The configured listener it attaches to; a route never creates one. */
9
+ listener: string;
10
+ path: { exact: string } | { prefix: string };
11
+ /** Omitted: every method, for a handler that answers the rest itself. */
12
+ methods?: readonly string[];
13
+ handle(request: Request): Response | Promise<Response>;
14
+ }
15
+
16
+ /** Where a listener serves: a unix socket, reached from outside through a tunnel, or a TCP port. */
17
+ export type ListenerAddress =
18
+ | { socketPath: string }
19
+ | { port: number; hostname?: string };
20
+
21
+ /** An address the host serves HTTP on, with the id routes name it by. */
22
+ export type ListenerConfig = { id: string } & ListenerAddress;
23
+
24
+ const pathOf = (route: HttpRoute) =>
25
+ "exact" in route.path ? route.path.exact : route.path.prefix;
26
+
27
+ const matches = (route: HttpRoute, path: string) =>
28
+ "exact" in route.path
29
+ ? path === route.path.exact
30
+ : path.startsWith(route.path.prefix);
31
+
32
+ /** Whether one request could match both routes. */
33
+ function overlaps(a: HttpRoute, b: HttpRoute): boolean {
34
+ const methods =
35
+ !a.methods || !b.methods || a.methods.some((m) => b.methods?.includes(m));
36
+ if (!methods) return false;
37
+ // Each side's path is a request path the other may match.
38
+ return matches(a, pathOf(b)) || matches(b, pathOf(a));
39
+ }
40
+
41
+ /** Refuses routes on unknown listeners, reused names, and any request two routes could both take. */
42
+ function validate(
43
+ listeners: readonly ListenerConfig[],
44
+ routes: readonly HttpRoute[],
45
+ ): void {
46
+ const ids = new Set(listeners.map((l) => l.id));
47
+ if (ids.size !== listeners.length)
48
+ throw new PluginError("a listener is configured twice");
49
+ routes.forEach((route, index) => {
50
+ if (!ids.has(route.listener))
51
+ throw new PluginError(
52
+ `route ${route.name} needs listener ${route.listener}, which is not configured`,
53
+ );
54
+ for (const other of routes.slice(0, index)) {
55
+ if (other.name === route.name)
56
+ throw new PluginError(`route ${route.name} is registered twice`);
57
+ if (other.listener === route.listener && overlaps(other, route))
58
+ throw new PluginError(
59
+ `routes ${other.name} and ${route.name} overlap on listener ${route.listener}`,
60
+ );
61
+ }
62
+ });
63
+ }
64
+
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(
67
+ routes: readonly HttpRoute[],
68
+ request: Request,
69
+ ): Response | Promise<Response> {
70
+ // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
71
+ const path = new URL(request.url).pathname;
72
+ const route = routes.find(
73
+ (r) =>
74
+ matches(r, path) && (!r.methods || r.methods.includes(request.method)),
75
+ );
76
+ return route
77
+ ? route.handle(request)
78
+ : new Response("Not found", { status: 404 });
79
+ }
80
+
81
+ /**
82
+ * Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
83
+ * turns and quiet model streams. Bun 1.4.2 honors `idleTimeout` on unix sockets, but its
84
+ * 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.)
86
+ */
87
+ function serveUnix(
88
+ socketPath: string,
89
+ fetch: (request: Request) => Response | Promise<Response>,
90
+ ): Server<undefined> {
91
+ rmSync(socketPath, { force: true });
92
+ const options = { unix: socketPath, idleTimeout: 0, fetch };
93
+ // SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
94
+ return Bun.serve(
95
+ options as unknown as Parameters<typeof Bun.serve>[0],
96
+ ) as Server<undefined>;
97
+ }
98
+
99
+ /** Serves HTTP on a TCP port with the same idle setting as the unix sockets. */
100
+ function serveTcp(
101
+ port: number,
102
+ hostname: string | undefined,
103
+ fetch: (request: Request) => Response | Promise<Response>,
104
+ ): Server<undefined> {
105
+ return Bun.serve({
106
+ port,
107
+ ...(hostname ? { hostname } : {}),
108
+ idleTimeout: 0,
109
+ fetch,
110
+ });
111
+ }
112
+
113
+ /** The host's HTTP listeners, each serving only the routes attached to it. */
114
+ // pi-lens-ignore: large-class — three members: validation in the constructor, start, and stop
115
+ export class HttpListeners {
116
+ readonly #listeners: readonly ListenerConfig[];
117
+ readonly #routes: readonly HttpRoute[];
118
+ #servers: Server<undefined>[] = [];
119
+
120
+ /** Validates every route before any socket opens. */
121
+ constructor(
122
+ listeners: readonly ListenerConfig[],
123
+ routes: readonly HttpRoute[],
124
+ ) {
125
+ validate(listeners, routes);
126
+ this.#listeners = listeners;
127
+ this.#routes = routes;
128
+ }
129
+
130
+ 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));
142
+ }
143
+ }
144
+ }
145
+
146
+ /** Stops accepting at once and cuts open connections, such as event streams, without waiting on them. */
147
+ stop(): void {
148
+ for (const server of this.#servers) void server.stop(true);
149
+ this.#servers = [];
150
+ }
151
+ }