pi-roundtable 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.
Files changed (158) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +618 -86
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +150 -86
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +42 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -7,7 +7,13 @@ import {
7
7
  SlashCommandSubcommandGroupBuilder,
8
8
  } from "discord.js";
9
9
  import { messages } from "../i18n/index.ts";
10
+ import type { IdentityService } from "../identity/identity-service.ts";
10
11
  import type { Logger } from "../log.ts";
12
+ import {
13
+ type DiscordActor,
14
+ DiscordOwners,
15
+ discordUser,
16
+ } from "./discord-owners.ts";
11
17
  import type {
12
18
  CommandGuard,
13
19
  CommandRoot,
@@ -33,31 +39,36 @@ export const groupOption = (
33
39
  ): RootOption => build(new SlashCommandSubcommandGroupBuilder()).toJSON();
34
40
 
35
41
  /**
36
- * Only the owner may use the root command, checked by user ID; every answer is visible only to them,
37
- * and a failure becomes a panel instead of a hanging interaction.
42
+ * Only an owner may use the root command: the primary owner by user ID, and, with the identity
43
+ * service, every other owner principal's Discord identity, checked again each time. Every answer
44
+ * is visible only to them, and a failure becomes a panel instead of a hanging interaction.
38
45
  */
39
- // pi-lens-ignore: large-class — two methods: the owner check and the failure panel
46
+ // pi-lens-ignore: large-class — three methods: the owner checks and the failure panel
40
47
  export class OwnerGuard implements CommandGuard {
41
- readonly #ownerId: string;
48
+ readonly #owners: DiscordOwners;
42
49
  readonly #logger: Logger;
43
50
  /** The name of the root command the guard serves, without the slash. */
44
51
  readonly root: string;
45
52
  readonly refusalHint: string | undefined;
46
53
 
47
54
  constructor(
48
- ownerId: string,
55
+ owners: DiscordOwners,
49
56
  logger: Logger,
50
57
  root: string,
51
58
  refusalHint?: string,
52
59
  ) {
53
- this.#ownerId = ownerId;
60
+ this.#owners = owners;
54
61
  this.#logger = logger;
55
62
  this.root = root;
56
63
  this.refusalHint = refusalHint;
57
64
  }
58
65
 
66
+ allows(actor: DiscordActor): Promise<boolean> {
67
+ return this.#owners.isOwner(discordUser(actor));
68
+ }
69
+
59
70
  isOwner(actor: { user: { id: string } }): boolean {
60
- return actor.user.id === this.#ownerId;
71
+ return actor.user.id === this.#owners.primary;
61
72
  }
62
73
 
63
74
  /** Runs a handler; failures become a panel instead of a hanging interaction. */
@@ -82,8 +93,10 @@ export class OwnerGuard implements CommandGuard {
82
93
 
83
94
  /** What `commandGuard` needs. */
84
95
  export interface CommandGuardOptions {
85
- /** The one user who may use the owner's commands. */
96
+ /** The primary owner's Discord user id, who may always use the owner's commands. */
86
97
  ownerId: string;
98
+ /** Which other owners there are and whether they still are; without it only `ownerId` may. */
99
+ identity?: Pick<IdentityService, "resolve" | "owners" | "identities">;
87
100
  /** The name of the root command the guard serves, without the slash. */
88
101
  root: string;
89
102
  /** Where a failing command is logged. */
@@ -94,9 +107,10 @@ export interface CommandGuardOptions {
94
107
 
95
108
  /** A guard for the owner's commands, such as the one a test hands a module; the Discord plugin makes its own. */
96
109
  export function commandGuard(options: CommandGuardOptions): CommandGuard {
110
+ const { ownerId, identity, logger } = options;
97
111
  return new OwnerGuard(
98
- options.ownerId,
99
- options.logger,
112
+ new DiscordOwners({ ownerId, ...(identity ? { identity } : {}), logger }),
113
+ logger,
100
114
  options.root,
101
115
  options.refusalHint,
102
116
  );
@@ -121,7 +135,7 @@ const refusal = (hint: string | undefined) =>
121
135
  sections: [`${messages().ownerRefusalBody}${hint ?? ""}`],
122
136
  });
123
137
 
124
- /** Answers a feature's root subcommands and components, for the owner only. */
138
+ /** Answers a feature's root subcommands and components, for the owners only. */
125
139
  export function ownerCommandModule(
126
140
  guard: CommandGuard,
127
141
  handlers: OwnerCommandHandlers,
@@ -139,14 +153,14 @@ export function ownerCommandModule(
139
153
  async handle(interaction) {
140
154
  if (interaction.isAutocomplete()) {
141
155
  if (!owned(interaction)) return false;
142
- if (!guard.isOwner(interaction)) await interaction.respond([]);
156
+ if (!(await guard.allows(interaction))) await interaction.respond([]);
143
157
  else await handlers.autocomplete?.(interaction);
144
158
  return true;
145
159
  }
146
160
  if (await handlers.component?.(interaction)) return true;
147
161
  if (!interaction.isChatInputCommand() || !owned(interaction))
148
162
  return false;
149
- if (!guard.isOwner(interaction)) {
163
+ if (!(await guard.allows(interaction))) {
150
164
  await interaction.reply(refusal(guard.refusalHint));
151
165
  return true;
152
166
  }
@@ -12,7 +12,7 @@ import type { CommandGuard, InteractionModule } from "./interaction-module.ts";
12
12
 
13
13
  export const STOP_BUTTON_ID = "roundtable:stop";
14
14
 
15
- /** The message a long turn shows: a stop button only the owner may press. */
15
+ /** The message a long turn shows: a stop button only an owner may press. */
16
16
  export function stopPanel() {
17
17
  return {
18
18
  content: messages().stopNote,
@@ -29,13 +29,13 @@ export function stopPanel() {
29
29
  }
30
30
 
31
31
  export interface StopButtonOptions {
32
- guard: Pick<CommandGuard, "isOwner">;
32
+ guard: Pick<CommandGuard, "allows">;
33
33
  /** Stops the running turn of the channel the button was pressed in, through its claim. */
34
34
  conversations: Pick<ConversationPort, "stop">;
35
35
  }
36
36
 
37
37
  /**
38
- * Answers a press of the stop button: only the owner stops the channel's turn, and the answer,
38
+ * Answers a press of the stop button: only an owner stops the channel's turn, and the answer,
39
39
  * visible only to whoever pressed, says whether one was running.
40
40
  */
41
41
  export function stopButtonModule(
@@ -46,7 +46,7 @@ export function stopButtonModule(
46
46
  handle: async (interaction: Interaction) => {
47
47
  if (!interaction.isButton() || interaction.customId !== STOP_BUTTON_ID)
48
48
  return false;
49
- const owner = options.guard.isOwner(interaction);
49
+ const owner = await options.guard.allows(interaction);
50
50
  const stopped =
51
51
  owner &&
52
52
  options.conversations.stop(
@@ -27,10 +27,15 @@ export interface PendingConfirmation {
27
27
  heldAt: Date;
28
28
  calls: HeldCall[];
29
29
  /**
30
- * The speaker whose turn held the calls. Besides the owner, only they may approve them, at a
31
- * tier that holds them; without one, the owner alone may.
30
+ * The speaker whose turn held the calls, by their id on the surface. Besides the owners, only
31
+ * they may approve them, at a tier that holds them; without one, the owners alone may.
32
32
  */
33
33
  speakerId?: string;
34
+ /**
35
+ * The principal of that speaker, who approves them on any of their identities; absent for
36
+ * calls held before 0.9, which are matched by `speakerId`.
37
+ */
38
+ principalId?: string;
34
39
  }
35
40
 
36
41
  /** A file produced for a reply; data is raw bytes, not a path or base64. */
@@ -29,3 +29,8 @@ export class DelegationError extends Error {
29
29
  export class AgentError extends Error {
30
30
  override name = "AgentError";
31
31
  }
32
+
33
+ /** A principal, identity link, or role change that cannot be made; its message names what to fix. */
34
+ export class IdentityError extends Error {
35
+ override name = "IdentityError";
36
+ }
@@ -7,6 +7,11 @@ import type { TurnProgress } from "./progress.ts";
7
7
 
8
8
  export type { AgentTurnScope };
9
9
 
10
+ /** Who a turn's conversation belongs to. */
11
+ export type TurnConversation =
12
+ | { visibility: "private"; principalId: string }
13
+ | { visibility: "shared" };
14
+
10
15
  export interface TurnRequest {
11
16
  /** Where the turn's message arrived and its attachments are saved. */
12
17
  channel: ChannelKey;
@@ -19,8 +24,18 @@ export interface TurnRequest {
19
24
  confirmed?: boolean;
20
25
  /** Set for a turn of an agent-server agent. */
21
26
  agent?: AgentTurnScope;
22
- /** The person the turn is for: who wrote the message, or who set up the work. */
23
- speaker?: Speaker;
27
+ /**
28
+ * The person the turn is for: who wrote the message, or who set up the work; the turn runs at
29
+ * their tier. Required: a turn without one is refused, never run as the owner's. A plugin that
30
+ * starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`.
31
+ */
32
+ speaker: Speaker;
33
+ /**
34
+ * Who the conversation belongs to, as the host records it: `private` to one principal, or
35
+ * `shared` by whoever its claim admits, which it is taken to be when absent. A private
36
+ * conversation's prompts are its person's alone; a shared one's escalate to the owners.
37
+ */
38
+ conversation?: TurnConversation;
24
39
  /**
25
40
  * The conversation's kind, fixed when its session is made: the string a claim's `startFresh`
26
41
  * returns, which picks the persona of a non-agent conversation. "owner" when absent.
@@ -54,6 +69,16 @@ export type {
54
69
  YesNoQuestion,
55
70
  } from "../contract/providers.ts";
56
71
 
72
+ /** Sends a person a notice through their own channel; resolves false when they have none. */
73
+ export interface Notifier {
74
+ notify(principalId: string, text: string): Promise<boolean>;
75
+ }
76
+
77
+ /**
78
+ * Sends the primary owner a direct message.
79
+ * @deprecated Since 0.9 there may be more than one owner: reach a person with a `Notifier`, such as
80
+ * `context.directChannels`. Goes away in 1.0.
81
+ */
57
82
  export interface OwnerNotifier {
58
83
  notifyOwner(text: string): Promise<void>;
59
84
  }
package/src/core/drain.ts CHANGED
@@ -1,19 +1,32 @@
1
- /** How long a shutdown waits for running work before aborting it (self-maintenance spec behavior 11). */
2
- export const DRAIN_LIMIT_MS = 60 * 60_000;
1
+ import type { Logger } from "./log.ts";
2
+
3
+ /**
4
+ * How long a shutdown waits for running work before aborting it: long enough for a usual turn to
5
+ * answer, short enough that a hung one never holds a restart for an hour.
6
+ */
7
+ export const DRAIN_LIMIT_MS = 3 * 60_000;
8
+
9
+ /** How long work that was told to stop gets to end before the shutdown goes on without it. */
10
+ const ABORT_GRACE_MS = 10_000;
3
11
 
4
12
  export interface DrainOptions {
5
13
  /** What still runs or waits, one entry per piece of work; empty when idle. */
6
14
  busy: () => string[];
15
+ /** The most the drain waits for running work; DRAIN_LIMIT_MS by default. */
7
16
  limitMs?: number;
17
+ /** How often it looks again; one second by default. */
8
18
  intervalMs?: number;
19
+ /** The most work told to stop at the limit gets to end; ABORT_GRACE_MS by default. */
20
+ abortGraceMs?: number;
9
21
  sleep?: (ms: number) => Promise<void>;
10
22
  now?: () => number;
11
23
  }
12
24
 
13
25
  /**
14
- * Waits until nothing runs or waits, checking every interval, for at most the limit. Work that
15
- * arrives meanwhile is served as usual and waited for too. Returns what was still busy when the
16
- * limit ran out, or an empty list once idle.
26
+ * Waits until nothing runs or waits, checking every interval, for at most the limit. Returns what
27
+ * was still busy when the limit ran out, or an empty list once idle. It only waits: the host stops
28
+ * work from starting before it calls this, and a caller that does not must expect work that
29
+ * arrives meanwhile to be waited for too.
17
30
  */
18
31
  export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
19
32
  const {
@@ -31,3 +44,41 @@ export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
31
44
  await sleep(intervalMs);
32
45
  }
33
46
  }
47
+
48
+ /**
49
+ * Waits for running work up to the limit; if some is left, tells it to stop (`abort` says whether
50
+ * it told any) and gives it the abort grace to end. Returns what the limit ran out on, even when stopping it worked, so the
51
+ * caller can hand it over as cut short.
52
+ */
53
+ export async function drainWork(
54
+ options: DrainOptions & { abort: (left: string[]) => boolean },
55
+ ): Promise<string[]> {
56
+ const { abort, abortGraceMs = ABORT_GRACE_MS, ...waiting } = options;
57
+ const left = await waitUntilIdle(waiting);
58
+ if (left.length === 0) return left;
59
+ // Work nothing could be told to stop would only be waited for in vain.
60
+ if (abort(left)) await waitUntilIdle({ ...waiting, limitMs: abortGraceMs });
61
+ return left;
62
+ }
63
+
64
+ /**
65
+ * Tells each channel's running turn to stop, as the owner's stop does; a failure is logged and the
66
+ * rest are still told. Whether any turn was running to be told.
67
+ */
68
+ export function stopTurns(
69
+ channels: readonly string[],
70
+ stop: (channel: string) => boolean,
71
+ logger: Logger,
72
+ ): boolean {
73
+ let stopped = false;
74
+ for (const channel of new Set(channels)) {
75
+ try {
76
+ const told = stop(channel);
77
+ logger.warn({ channel, told }, "stopping the turn");
78
+ stopped ||= told;
79
+ } catch (error) {
80
+ logger.error({ channel, err: error }, "the turn could not be stopped");
81
+ }
82
+ }
83
+ return stopped;
84
+ }
@@ -27,3 +27,16 @@ export class JudgeError extends Error {
27
27
  export class ProviderError extends Error {
28
28
  override name = "ProviderError";
29
29
  }
30
+
31
+ /**
32
+ * Work refused because the host is shutting down: once the drain starts, no turn, queue task, or
33
+ * background run starts any more, and what was waiting behind a running one is dropped.
34
+ */
35
+ export class HostStoppingError extends Error {
36
+ override name = "HostStoppingError";
37
+ constructor() {
38
+ super(
39
+ "the host is shutting down and starts no new work; try again once it is back",
40
+ );
41
+ }
42
+ }
@@ -0,0 +1,56 @@
1
+ import type { Logger } from "./log.ts";
2
+ import type { ServiceStartedEvent } from "./plugin.ts";
3
+ import type { Registry } from "./registry/contributions.ts";
4
+
5
+ type Attempt = { ok: true } | { ok: false; error: unknown };
6
+
7
+ /** Runs one step whose failure the caller reports and moves past. */
8
+ export async function attempt(
9
+ step: () => Promise<void> | void,
10
+ ): Promise<Attempt> {
11
+ try {
12
+ await step();
13
+ return { ok: true };
14
+ } catch (error) {
15
+ return { ok: false, error };
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Runs every service's background start at once and tells every plugin, as each ends, how it
21
+ * went; the rest of the process runs either way, and a failing start or handler never stops the
22
+ * others.
23
+ */
24
+ export async function startInBackground(
25
+ registry: Registry,
26
+ logger: Logger,
27
+ ): Promise<void> {
28
+ const { services, servicePlugins, handlers } = registry;
29
+ await Promise.all(
30
+ // pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
31
+ services.map(async (service) => {
32
+ if (!service.startInBackground) return;
33
+ const plugin = servicePlugins.get(service) ?? "unknown";
34
+ const started = await attempt(() => service.startInBackground?.());
35
+ if (started.ok) logger.info({ plugin, service: service.name }, "ready");
36
+ else
37
+ logger.error(
38
+ { plugin, service: service.name, err: started.error },
39
+ "service did not start in the background",
40
+ );
41
+ const event: ServiceStartedEvent = {
42
+ plugin,
43
+ service: service.name,
44
+ outcome: started.ok ? "ready" : "failed",
45
+ };
46
+ for (const { plugin: heard, events } of handlers) {
47
+ const handled = await attempt(() => events.serviceStarted?.(event));
48
+ if (!handled.ok)
49
+ logger.error(
50
+ { plugin: heard, err: handled.error },
51
+ "service started handler failed",
52
+ );
53
+ }
54
+ }),
55
+ );
56
+ }
package/src/core/host.ts CHANGED
@@ -2,11 +2,13 @@ import type { SQL } from "bun";
2
2
  import type { ConversationPort } from "./contract/channels.ts";
3
3
  import type { SurfacePort } from "./contract/surface.ts";
4
4
  import { openPool, runMigrations } from "./db/migrations.ts";
5
- import { type DrainOptions, waitUntilIdle } from "./drain.ts";
5
+ import { type DrainOptions, drainWork, stopTurns } from "./drain.ts";
6
6
  import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
7
7
  import { EventBus } from "./events.ts";
8
+ import { attempt, startInBackground } from "./host-background.ts";
8
9
  import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
9
10
  import { type Locale, setLocale } from "./i18n/index.ts";
11
+ import { contactsOf } from "./identity/identity-view.ts";
10
12
  import type { JudgeModel } from "./judging/model-judge.ts";
11
13
  import type { Logger } from "./log.ts";
12
14
  import type {
@@ -14,7 +16,6 @@ import type {
14
16
  LinkedSessions,
15
17
  RoundtablePlugin,
16
18
  Service,
17
- ServiceStartedEvent,
18
19
  } from "./plugin.ts";
19
20
  import { refuseRemovedFields } from "./plugin.ts";
20
21
  import {
@@ -32,7 +33,8 @@ import {
32
33
  conversationTurns,
33
34
  } from "./routing/conversation-turns.ts";
34
35
  import { surfacePort } from "./routing/surface-port.ts";
35
- import { CONVERSATIONS, RUNTIME } from "./services.ts";
36
+ import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
37
+ import type { ChannelKey } from "./sessions.ts";
36
38
  import { setTimeZone } from "./time.ts";
37
39
  import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
38
40
 
@@ -77,7 +79,11 @@ export interface RoundtableOptions {
77
79
  database?: { url: string };
78
80
  /** Receives the work a shutdown drain gave up on, before any service stops. */
79
81
  aborted?: (left: string[]) => Promise<void>;
80
- /** The drain's limit and clock; tests shorten them. */
82
+ /**
83
+ * The shutdown drain: it waits up to `limitMs` (3 minutes by default) for the turns already
84
+ * running, then stops them, gives them `abortGraceMs` (10 seconds) to end, and goes on without
85
+ * them. Nothing new starts once it begins. Tests shorten the limits and the clock.
86
+ */
81
87
  drain?: Omit<DrainOptions, "busy">;
82
88
  /** What `listen()` calls with the shutdown's exit code; the process's own exit by default. */
83
89
  exit?: (code: number) => void;
@@ -86,63 +92,13 @@ export interface RoundtableOptions {
86
92
  /** The host running in this process; the text catalog, time zone and environment are process-wide. */
87
93
  let running: Roundtable | undefined;
88
94
 
89
- type Attempt = { ok: true } | { ok: false; error: unknown };
90
-
91
- /** Runs one step whose failure the caller reports and moves past. */
92
- async function attempt(step: () => Promise<void> | void): Promise<Attempt> {
93
- try {
94
- await step();
95
- return { ok: true };
96
- } catch (error) {
97
- return { ok: false, error };
98
- }
99
- }
100
-
101
- /**
102
- * Runs every service's background start at once and tells every plugin, as each ends, how it
103
- * went; the rest of the process runs either way, and a failing start or handler never stops the
104
- * others.
105
- */
106
- async function startInBackground(
107
- registry: Registry,
108
- logger: Logger,
109
- ): Promise<void> {
110
- const { services, servicePlugins, handlers } = registry;
111
- await Promise.all(
112
- // pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
113
- services.map(async (service) => {
114
- if (!service.startInBackground) return;
115
- const plugin = servicePlugins.get(service) ?? "unknown";
116
- const started = await attempt(() => service.startInBackground?.());
117
- if (started.ok) logger.info({ plugin, service: service.name }, "ready");
118
- else
119
- logger.error(
120
- { plugin, service: service.name, err: started.error },
121
- "service did not start in the background",
122
- );
123
- const event: ServiceStartedEvent = {
124
- plugin,
125
- service: service.name,
126
- outcome: started.ok ? "ready" : "failed",
127
- };
128
- for (const { plugin: heard, events } of handlers) {
129
- const handled = await attempt(() => events.serviceStarted?.(event));
130
- if (!handled.ok)
131
- logger.error(
132
- { plugin: heard, err: handled.error },
133
- "service started handler failed",
134
- );
135
- }
136
- }),
137
- );
138
- }
139
-
140
95
  /**
141
96
  * The process around the plugins: it sets them all up, links what they add (session parts,
142
97
  * HTTP routes) and runs the preflight, then starts their services in order and the
143
98
  * HTTP listeners last, then runs the services' background starts. Nothing reaches Discord or a listener unless
144
- * every setup, link, and the preflight succeeded. On shutdown it waits until no work runs or
145
- * waits before closing the listeners and stopping the services in reverse.
99
+ * every setup, link, and the preflight succeeded. On shutdown it starts no new work, waits for
100
+ * what runs for at most the drain limit, then closes the listeners and stops the services in
101
+ * reverse.
146
102
  */
147
103
  // pi-lens-ignore: large-class
148
104
  export class Roundtable {
@@ -154,6 +110,7 @@ export class Roundtable {
154
110
  #listeners: HttpListeners | undefined;
155
111
  #pool: SQL | undefined;
156
112
  readonly #queue = new ChannelQueue();
113
+ readonly #warned = new Set<string>();
157
114
  readonly #tiers: ToolTierTable;
158
115
  readonly #events: EventBus;
159
116
  #services: ServiceRegistry | undefined;
@@ -191,6 +148,7 @@ export class Roundtable {
191
148
  return {
192
149
  handle: (message) => router().handle(message),
193
150
  background: (turn) => router().background(turn),
151
+ runsAs: (turn) => router().runsAs(turn),
194
152
  target: (name) => router().target(name),
195
153
  startFresh: (channel) => router().startFresh(channel),
196
154
  deleteConversation: (channel) => router().deleteConversation(channel),
@@ -198,18 +156,29 @@ export class Roundtable {
198
156
  postsInPlace: (channel) => router().postsInPlace(channel),
199
157
  owns: (channel) => router().owns(channel),
200
158
  takesBackground: (channel) => router().takesBackground(channel),
159
+ takesSystemReports: (channel) => router().takesSystemReports(channel),
201
160
  };
202
161
  }
203
162
 
204
163
  /** The contributed surfaces by channel prefix; every call before linking throws NotLinkedError. */
205
164
  #surfaces(): SurfacePort {
206
- return surfacePort(() => {
207
- if (!this.#sessions)
208
- throw new NotLinkedError(
209
- "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
210
- );
211
- return this.#registry.surfaces;
212
- });
165
+ return surfacePort(
166
+ () => {
167
+ if (!this.#sessions)
168
+ throw new NotLinkedError(
169
+ "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
170
+ );
171
+ return this.#registry.surfaces;
172
+ },
173
+ { deprecated: (message) => this.#deprecated(message) },
174
+ );
175
+ }
176
+
177
+ /** Logs a deprecation once per host. */
178
+ #deprecated(message: string): void {
179
+ if (this.#warned.has(message)) return;
180
+ this.#warned.add(message);
181
+ this.#options.logger.warn(message);
213
182
  }
214
183
 
215
184
  /** Turns over the runtime plugin's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
@@ -229,6 +198,7 @@ export class Roundtable {
229
198
  registry: () => this.#services?.find(CONVERSATIONS),
230
199
  surfaces: this.#surfaces(),
231
200
  events: this.#events.sink,
201
+ stopping: () => this.#queue.closed,
232
202
  selection: () => {
233
203
  if (!this.#sessions)
234
204
  throw new NotLinkedError("session parts are not linked yet.");
@@ -328,11 +298,16 @@ export class Roundtable {
328
298
  const { routes, channels } = this.#registry;
329
299
  this.#sessions = linkSessions(this.#registry);
330
300
  this.#events.link(this.#registry.handlers);
301
+ const identity = this.#services.find(IDENTITY);
331
302
  this.#router = new ChannelRouter({
332
303
  claims: channels,
304
+ ...(identity
305
+ ? { contacts: contactsOf(identity), principals: identity }
306
+ : {}),
333
307
  targets: (name) =>
334
308
  this.#registry.backgroundTargets.find((t) => t.name === name),
335
309
  queue: this.#queue,
310
+ surfaces: this.#surfaces(),
336
311
  logger,
337
312
  ...(this.#options.conversations?.forwardJoinMs === undefined
338
313
  ? {}
@@ -404,7 +379,8 @@ export class Roundtable {
404
379
  }
405
380
 
406
381
  /**
407
- * Stops the host once no work runs or waits and returns the exit code: 0, or 1 when the
382
+ * Stops the host once no work runs or waits, or the drain limit has passed, and returns the
383
+ * exit code: 0, or 1 when the
408
384
  * boot had failed or a listener, service or the pool did not stop. Every call shares the one
409
385
  * shutdown.
410
386
  */
@@ -418,19 +394,27 @@ export class Roundtable {
418
394
  // A signal during the boot waits for it to settle; a failed boot has already stopped.
419
395
  await this.#booting?.catch(() => undefined);
420
396
  if (running !== this) return this.#bootFailed ? 1 : 0;
421
- // Everything keeps serving until nothing runs or waits, so a deploy never cuts a turn short.
397
+ // Turns already running finish, but nothing new starts from here on, whoever asks.
422
398
  logger.info({ signal }, "shutting down once idle");
423
- const left = await waitUntilIdle({
399
+ this.#queue.close();
400
+ const left = await drainWork({
424
401
  ...drain,
425
402
  busy: () => [
426
403
  ...this.#queue.busy(),
427
404
  ...this.#started.flatMap((service) => service.busy?.() ?? []),
428
405
  ],
406
+ // A turn the limit finds still running is stopped, as the owner's stop would.
407
+ abort: () =>
408
+ stopTurns(
409
+ this.#queue.busy(),
410
+ (channel) => this.#router?.stop(channel as ChannelKey) ?? false,
411
+ logger,
412
+ ),
429
413
  });
430
414
  if (left.length > 0) {
431
415
  logger.warn(
432
416
  { aborted: left },
433
- "still busy after the drain limit; aborting",
417
+ "still busy after the drain limit; aborted",
434
418
  );
435
419
  const recorded = await attempt(() => aborted?.(left));
436
420
  if (!recorded.ok)
@@ -11,10 +11,12 @@ export function cardsEn(ctx: CatalogContext) {
11
11
  cardStopped: "⏹️ This round was stopped.",
12
12
  cardExpired: "⌛ Timed out with no answer.",
13
13
  cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
14
- cardApproversNote: (userId: string) => `-# Who can approve: <@${userId}>`,
14
+ cardApproversNote: (...userIds: readonly string[]) =>
15
+ `-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
15
16
  cardApproversRefusal:
16
17
  "This approval is for the speaker whose turn asked for it.",
17
- cardAskerNote: (userId: string) => `-# Who can answer: <@${userId}>`,
18
+ cardAskerNote: (...userIds: readonly string[]) =>
19
+ `-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
18
20
  cardAskerRefusal: "This question is for another speaker.",
19
21
  cardInactive: `This card is no longer active (it timed out, or ${ctx.assistant} restarted); reply in text if you need to.`,
20
22
  cardApproved: "✅ Approved to run.",
@@ -41,9 +43,11 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
41
43
  cardStopped: "⏹️ 這一輪已停止。",
42
44
  cardExpired: "⌛ 已逾時,沒有回答。",
43
45
  cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
44
- cardApproversNote: (userId: string) => `-# 可以核准的人:<@${userId}>`,
46
+ cardApproversNote: (...userIds: readonly string[]) =>
47
+ `-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
45
48
  cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
46
- cardAskerNote: (userId: string) => `-# 可以回答的人:<@${userId}>`,
49
+ cardAskerNote: (...userIds: readonly string[]) =>
50
+ `-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
47
51
  cardAskerRefusal: "這張問題是問另一位發話者的。",
48
52
  cardInactive: `這張卡片已經失效(逾時,或 ${ctx.assistant} 重新啟動過);需要的話直接打字回覆。`,
49
53
  cardApproved: "✅ 已核准執行。",
@@ -6,6 +6,7 @@ export function conversationEn(ctx: CatalogContext) {
6
6
  failureNotice:
7
7
  "Sorry, this reply did not go through. Please try again later.",
8
8
  stoppedNotice: "-# Stopped.",
9
+ restartingNotice: `-# ${ctx.assistant} is restarting and takes no new messages right now; please send this again in a few minutes.`,
9
10
  roundLimitNotice:
10
11
  "-# This round already has 8 replies, so it stops here; call us again if you need more.",
11
12
  restartNotice: `-# ${ctx.assistant} restarted before this job finished, so nothing more will be reported here.`,
@@ -30,6 +31,7 @@ export function conversationZhTW(
30
31
  return {
31
32
  failureNotice: "抱歉,這次沒能完成回覆,請稍後再試一次。",
32
33
  stoppedNotice: "-# 已停止。",
34
+ restartingNotice: `-# ${ctx.assistant} 正在重新啟動,暫時不接新訊息;請過幾分鐘再傳一次。`,
33
35
  roundLimitNotice:
34
36
  "-# 這一輪已經有 8 則回覆,先停在這裡;需要的話請再叫我們。",
35
37
  restartNotice: `-# ${ctx.assistant} 在這項工作結束前重新啟動了,這裡不會再有回報。`,