pi-roundtable 0.7.18 → 0.8.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 (74) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +268 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/builtin/stores.ts +29 -14
  24. package/src/core/config/adapters.ts +57 -0
  25. package/src/core/config/config.ts +174 -77
  26. package/src/core/contract/channels.ts +4 -0
  27. package/src/core/contract/providers.ts +3 -2
  28. package/src/core/contract/runtime.ts +8 -4
  29. package/src/core/contract/surface.ts +13 -0
  30. package/src/core/conversations/conversation-registry.ts +42 -0
  31. package/src/core/conversations/conversation-store.ts +113 -0
  32. package/src/core/conversations/conversations-plugin.ts +29 -0
  33. package/src/core/define-roundtable.ts +124 -54
  34. package/src/core/discord/discord-adapter.ts +9 -0
  35. package/src/core/discord/owner-cards.ts +19 -8
  36. package/src/core/domain/conversation.ts +5 -0
  37. package/src/core/domain/owner-prompts.ts +1 -1
  38. package/src/core/domain/ports.ts +6 -0
  39. package/src/core/domain/progress.ts +10 -0
  40. package/src/core/events.ts +1 -0
  41. package/src/core/host.ts +6 -3
  42. package/src/core/http/listeners.ts +174 -21
  43. package/src/core/http/websocket.ts +316 -0
  44. package/src/core/i18n/cards.ts +5 -7
  45. package/src/core/modules/memory/owner-memory.ts +5 -3
  46. package/src/core/ops/error-reporter.ts +24 -12
  47. package/src/core/plugin.ts +14 -0
  48. package/src/core/registry/providers.ts +1 -1
  49. package/src/core/routing/channel-router.ts +8 -0
  50. package/src/core/routing/conversation-turns.ts +55 -1
  51. package/src/core/routing/surface-port.ts +3 -0
  52. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  53. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  54. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  55. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  56. package/src/core/runtime/prompt-slot.ts +2 -2
  57. package/src/core/runtime/runtime-plugin.ts +160 -0
  58. package/src/core/runtime/turn-progress.ts +116 -0
  59. package/src/core/services.ts +15 -1
  60. package/src/core/shared/unix-server.ts +35 -5
  61. package/src/core/testing/modules.ts +47 -23
  62. package/src/core/testing/surface-contract.ts +383 -0
  63. package/src/core/testing/test-host.ts +27 -9
  64. package/src/discord/index.ts +2 -0
  65. package/src/index.ts +18 -0
  66. package/src/testing.ts +41 -5
  67. package/templates/adapters/web/.env.example +24 -0
  68. package/templates/adapters/web/README.md +15 -0
  69. package/templates/adapters/web/package.json.tmpl +26 -0
  70. package/templates/adapters/web/persona/assistant.md +2 -0
  71. package/templates/adapters/web/roundtable.config.ts +55 -0
  72. package/templates/package.json.tmpl +1 -0
  73. package/templates/plugins/self-compact.ts +11 -0
  74. package/templates/roundtable.config.ts +2 -1
@@ -0,0 +1,72 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import {
3
+ AGENTS,
4
+ defineRoundtable,
5
+ type PluginContext,
6
+ Roundtable,
7
+ RUNTIME,
8
+ } from "pi-roundtable";
9
+ import { DISCORD } from "pi-roundtable/discord";
10
+ import {
11
+ describeDb,
12
+ silentLogger,
13
+ testDatabaseUrl,
14
+ } from "pi-roundtable/testing";
15
+ import { echoRuntime } from "./echo-runtime.ts";
16
+ import { FakeSurface } from "./fake-surface.ts";
17
+ import { studyHall } from "./headless.ts";
18
+
19
+ let roundtable: Roundtable | undefined;
20
+ afterEach(async () => {
21
+ await roundtable?.shutdown("test");
22
+ roundtable = undefined;
23
+ });
24
+
25
+ async function until(done: () => boolean): Promise<void> {
26
+ for (let waited = 0; !done() && waited < 2000; waited += 5)
27
+ await Bun.sleep(5);
28
+ expect(done()).toBe(true);
29
+ }
30
+
31
+ describeDb("a host without Discord", () => {
32
+ test("boots without Discord or the agent server and answers a study room through its runtime", async () => {
33
+ const surface = new FakeSurface();
34
+ const config = studyHall(surface, {
35
+ databaseUrl: testDatabaseUrl,
36
+ dataDir: `${Bun.env.TMPDIR ?? "/tmp"}/study-hall-${crypto.randomUUID()}`,
37
+ });
38
+ let context: PluginContext | undefined;
39
+ const { options, plugins } = await defineRoundtable(
40
+ {
41
+ ...config,
42
+ plugins: [
43
+ ...(config.plugins ?? []),
44
+ // The echo runtime stands in for Pi, so the test calls no model.
45
+ echoRuntime,
46
+ {
47
+ name: "probe",
48
+ setup: (given) => {
49
+ context = given;
50
+ return { services: [{ name: "probe" }] };
51
+ },
52
+ },
53
+ ],
54
+ },
55
+ { logger: silentLogger() },
56
+ );
57
+ const names = plugins.map((plugin) => plugin.name);
58
+ expect(names).not.toContain("discord");
59
+ expect(names).not.toContain("agent-server");
60
+ expect(options.listeners).toEqual([]);
61
+ roundtable = new Roundtable(options, plugins);
62
+ await roundtable.run();
63
+ expect(context?.services.find(DISCORD)).toBeUndefined();
64
+ expect(context?.services.find(AGENTS)).toBeUndefined();
65
+ expect(context?.services.get(RUNTIME)).toBeDefined();
66
+ surface.say("fake:study-algebra", "What is a group?");
67
+ await until(() => surface.replies.length > 0);
68
+ expect(surface.replies[0]?.reply.chunks.join()).toContain(
69
+ "What is a group?",
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,31 @@
1
+ import { definePlugin, type RoundtableConfig } from "pi-roundtable";
2
+ import type { FakeSurface } from "./fake-surface.ts";
3
+ import { studyRoom } from "./study-room.ts";
4
+
5
+ /** A plugin that brings a chat network and nothing else; the study room's claim answers its rooms. */
6
+ export function chatNetwork(surface: FakeSurface) {
7
+ return definePlugin({
8
+ name: "chat-network",
9
+ setup: () => ({ surfaces: [surface] }),
10
+ });
11
+ }
12
+
13
+ /**
14
+ * A host without Discord: no `discord` key, so no Discord plugin, no agent server, no agents and
15
+ * no skills; and no `http`, so no listener opens until a plugin needs one. Conversations come
16
+ * through the plugins' own chat surfaces, and `context.turns` runs them on the host's runtime,
17
+ * Pi's unless a plugin fills the `runtime` slot.
18
+ */
19
+ export function studyHall(
20
+ surface: FakeSurface,
21
+ where: { databaseUrl: string; dataDir: string },
22
+ ): RoundtableConfig {
23
+ return {
24
+ name: "Study Hall",
25
+ owner: { id: "owner", name: "Ada" },
26
+ database: { url: where.databaseUrl },
27
+ dataDir: where.dataDir,
28
+ model: "anthropic/claude-sonnet-5-5",
29
+ plugins: [chatNetwork(surface), studyRoom],
30
+ };
31
+ }
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from "bun:test";
2
+ import type { RouteSocket } from "pi-roundtable";
2
3
  import { testPlugin } from "pi-roundtable/testing";
3
- import { health } from "./http.ts";
4
+ import { echo, health } from "./http.ts";
4
5
 
5
6
  test("the route answers GET /healthz", async () => {
6
7
  const harness = await testPlugin(health);
@@ -10,3 +11,32 @@ test("the route answers GET /healthz", async () => {
10
11
  expect(await response?.text()).toBe("ok");
11
12
  await harness.stop();
12
13
  });
14
+
15
+ test("the echo route admits a ticket's holder once and echoes what it is sent", async () => {
16
+ const harness = await testPlugin(echo(new Set(["t-1"])));
17
+ const websocket = harness.contribution.http?.[0]?.websocket;
18
+ const refused = await websocket?.accept(
19
+ new Request("http://localhost/echo?ticket=wrong"),
20
+ );
21
+ expect(refused instanceof Response && refused.status).toBe(401);
22
+ const accepted = await websocket?.accept(
23
+ new Request("http://localhost/echo?ticket=t-1"),
24
+ );
25
+ expect(accepted).toMatchObject({ data: { since: expect.any(Number) } });
26
+ const replayed = await websocket?.accept(
27
+ new Request("http://localhost/echo?ticket=t-1"),
28
+ );
29
+ expect(replayed instanceof Response && replayed.status).toBe(401);
30
+ const sent: unknown[] = [];
31
+ const socket: RouteSocket = {
32
+ data: undefined,
33
+ send: (message) => {
34
+ sent.push(message);
35
+ return "sent";
36
+ },
37
+ close: () => undefined,
38
+ };
39
+ await websocket?.message(socket, "hi");
40
+ expect(sent).toEqual(["hi"]);
41
+ await harness.stop();
42
+ });
package/examples/http.ts CHANGED
@@ -15,3 +15,42 @@ export const health = definePlugin({
15
15
  ],
16
16
  }),
17
17
  });
18
+
19
+ /**
20
+ * A route with `websocket` also takes upgrades. The Origin check and `accept` run before any
21
+ * socket opens, so a browser on another site, or a client without a ticket, never connects.
22
+ * A ticket opens one socket: `accept` spends it, so one that leaks from a log or the browser's
23
+ * history opens nothing. Whoever issues the tickets should also let them expire within seconds.
24
+ */
25
+ export const echo = (tickets: Set<string>) =>
26
+ definePlugin({
27
+ name: "echo",
28
+ setup: () => ({
29
+ http: [
30
+ {
31
+ name: "echo",
32
+ listener: "public",
33
+ path: { exact: "/echo" },
34
+ methods: ["GET"],
35
+ handle: () => new Response("Upgrade Required", { status: 426 }),
36
+ websocket: {
37
+ origins: ["https://chat.example.com"],
38
+ accept: (request) => {
39
+ const ticket = URL.parse(request.url)?.searchParams.get("ticket");
40
+ return ticket && tickets.delete(ticket)
41
+ ? { data: { since: Date.now() } }
42
+ : new Response("Unauthorized", { status: 401 });
43
+ },
44
+ maxMessageBytes: 4096,
45
+ rate: { messages: 20, perMs: 10_000 },
46
+ maxBufferedBytes: 64 * 1024,
47
+ maxConnections: 50,
48
+ message: (socket, message) => {
49
+ // "dropped" means the client stopped reading and the host cut it; an echo has nothing to resend.
50
+ socket.send(message);
51
+ },
52
+ },
53
+ },
54
+ ],
55
+ }),
56
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.18",
4
- "description": "A plugin-driven Pi agent server for Discord",
3
+ "version": "0.8.0",
4
+ "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "workspaces": [
@@ -74,6 +74,7 @@
74
74
  "typescript": "7.0.2",
75
75
  "@biomejs/biome": "2.5.15",
76
76
  "@types/bun": "1.4.2",
77
+ "pi-self-compact": "0.2.0",
77
78
  "pi-web-access": "0.35.0"
78
79
  }
79
80
  }
package/src/cli/cli.ts CHANGED
@@ -14,7 +14,7 @@ import { loadConfigFile, type Ports } from "./project.ts";
14
14
  import { formatOutcomes } from "./report.ts";
15
15
  import { assemble, providerLogin } from "./runtime.ts";
16
16
  import { start } from "./start.ts";
17
- import { OFFICIAL_PLUGINS } from "./templates.ts";
17
+ import { ADAPTERS, isAdapter, OFFICIAL_PLUGINS } from "./templates.ts";
18
18
 
19
19
  /** What the command line reads from its surroundings; tests replace every part. */
20
20
  export interface CliEnvironment {
@@ -32,7 +32,9 @@ export interface CliEnvironment {
32
32
 
33
33
  const USAGE = `roundtable: a Discord agent server on Pi
34
34
 
35
- roundtable init [dir] create a project (default: the current directory)
35
+ roundtable init [dir] [--adapter discord|web]
36
+ create a project (default: the current directory) that talks
37
+ through Discord (the default) or through the web chat
36
38
  roundtable doctor [--reachable] check the setup and say how to fix what is wrong
37
39
  roundtable start run the checks that need no network, then the bot
38
40
  roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
@@ -108,13 +110,22 @@ export async function runCli(
108
110
  return 0;
109
111
  }
110
112
  if (command === "init") {
111
- const [dir, ...extra] = rest;
112
- if (extra.length > 0) return usage(io, "init takes at most one directory");
113
+ const at = rest.indexOf("--adapter");
114
+ const adapter = at === -1 ? "discord" : rest[at + 1];
115
+ if (adapter === undefined || !isAdapter(adapter))
116
+ return usage(
117
+ io,
118
+ `--adapter takes one of ${ADAPTERS.join(", ")}, not ${JSON.stringify(adapter ?? "")}`,
119
+ );
120
+ const [dir, ...extra] = at === -1 ? rest : rest.toSpliced(at, 2);
121
+ if (extra.length > 0 || dir?.startsWith("-"))
122
+ return usage(io, "init takes at most one directory");
113
123
  const report = init({
114
124
  cwd: io.cwd,
115
125
  ...(dir === undefined ? {} : { dir }),
116
126
  bun: io.bun(),
117
127
  version: io.version(),
128
+ adapter,
118
129
  });
119
130
  if (!report.ok) {
120
131
  for (const line of report.problems) io.err(line);
package/src/cli/doctor.ts CHANGED
@@ -14,7 +14,14 @@ import { checkModelLogin } from "./checks/model.ts";
14
14
  import { checkPublicUrl } from "./checks/public-url.ts";
15
15
  import type { Http } from "./http.ts";
16
16
  import { type Ports, Project } from "./project.ts";
17
- import { type Check, failed, type Outcome, runChecks } from "./report.ts";
17
+ import {
18
+ type Check,
19
+ failed,
20
+ type Outcome,
21
+ type Result,
22
+ runChecks,
23
+ skipped,
24
+ } from "./report.ts";
18
25
 
19
26
  export interface DoctorInputs {
20
27
  cwd: string;
@@ -39,6 +46,11 @@ export function buildChecks(
39
46
  ): Check[] {
40
47
  const { cwd, env, bun, ports, database, http, reachable } = inputs;
41
48
  const discord = new Discord(project, http);
49
+ // A project without Discord asks Discord nothing.
50
+ const ifDiscord = (run: () => Promise<Result>) => async (): Promise<Result> =>
51
+ (await project.leavesOut("discord"))
52
+ ? skipped("no Discord is configured")
53
+ : run();
42
54
  return [
43
55
  { name: "Bun", offline: true, run: async () => checkBun(bun) },
44
56
  {
@@ -62,21 +74,25 @@ export function buildChecks(
62
74
  offline: false,
63
75
  run: () => checkDatabase(project, database),
64
76
  },
65
- { name: "Discord token", offline: false, run: () => checkToken(discord) },
77
+ {
78
+ name: "Discord token",
79
+ offline: false,
80
+ run: ifDiscord(() => checkToken(discord)),
81
+ },
66
82
  {
67
83
  name: "Discord guild",
68
84
  offline: false,
69
- run: () => checkGuild(project, discord),
85
+ run: ifDiscord(() => checkGuild(project, discord)),
70
86
  },
71
87
  {
72
88
  name: "Discord intents",
73
89
  offline: false,
74
- run: () => checkIntents(discord),
90
+ run: ifDiscord(() => checkIntents(discord)),
75
91
  },
76
92
  {
77
93
  name: "Discord channel",
78
94
  offline: false,
79
- run: () => checkChannel(project, discord),
95
+ run: ifDiscord(() => checkChannel(project, discord)),
80
96
  },
81
97
  {
82
98
  name: "model login",
package/src/cli/init.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { basename, join, relative, resolve } from "node:path";
3
3
  import { type BunFacts, checkBun } from "./checks/bun.ts";
4
- import { type Rendered, renderProject, writeRendered } from "./templates.ts";
4
+ import {
5
+ type Adapter,
6
+ type Rendered,
7
+ renderProject,
8
+ writeRendered,
9
+ } from "./templates.ts";
5
10
 
6
11
  export interface InitInputs {
7
12
  cwd: string;
@@ -12,6 +17,8 @@ export interface InitInputs {
12
17
  version: string;
13
18
  /** Where the templates are; default the package's own. */
14
19
  templates?: string;
20
+ /** The chat network the project talks through; default Discord. */
21
+ adapter?: Adapter;
15
22
  }
16
23
 
17
24
  export interface InitReport {
@@ -61,6 +68,7 @@ export function init(inputs: InitInputs): InitReport {
61
68
  const files: Rendered[] = renderProject(
62
69
  { project: projectName(root), version: inputs.version },
63
70
  inputs.templates,
71
+ inputs.adapter,
64
72
  );
65
73
  const existing = files
66
74
  .map((file) => file.path)
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import { liftAdapters } from "../core/config/adapters.ts";
4
5
  import {
5
6
  type ResolvedConfig,
6
7
  type RoundtableConfig,
@@ -51,6 +52,16 @@ export interface Assembled {
51
52
  const message = (error: unknown): string =>
52
53
  error instanceof Error ? error.message : String(error);
53
54
 
55
+ /** The configuration with its adapters lifted, or as written when they cannot be. */
56
+ // pi-lens-ignore: no-unknown-returns — the configuration as written is untyped until the schema checks it
57
+ function lifted(value: unknown): unknown {
58
+ try {
59
+ return liftAdapters(value);
60
+ } catch {
61
+ return value;
62
+ }
63
+ }
64
+
54
65
  /**
55
66
  * The project's configuration, loaded and resolved at most once and shared by every check, so a
56
67
  * mistake in it is reported by the configuration check and the checks that need it say so
@@ -67,10 +78,14 @@ export class Project {
67
78
  this.#ports = ports;
68
79
  }
69
80
 
70
- /** The exported value as written, before the schema looks at it. */
81
+ /**
82
+ * The exported value as written, before the schema looks at it, with its adapters' settings in
83
+ * the places the host reads them, such as a Discord adapter's as `discord`. Adapters it cannot
84
+ * read stay as written; the configuration check reports them.
85
+ */
71
86
  raw(): Promise<Loaded<unknown>> {
72
87
  this.#raw ??= this.#ports.loadConfig(this.#cwd).then(
73
- (value): Loaded<unknown> => ({ ok: true, value }),
88
+ (value): Loaded<unknown> => ({ ok: true, value: lifted(value) }),
74
89
  (error: unknown): Loaded<unknown> => ({
75
90
  ok: false,
76
91
  failure: fail(
@@ -96,6 +111,17 @@ export class Project {
96
111
  return typeof value === "string" && value.trim() !== "" ? value : undefined;
97
112
  }
98
113
 
114
+ /** Whether the configuration, loaded, leaves the top-level key out; false when it could not be loaded. */
115
+ async leavesOut(key: string): Promise<boolean> {
116
+ const raw = await this.raw();
117
+ return (
118
+ raw.ok &&
119
+ typeof raw.value === "object" &&
120
+ raw.value !== null &&
121
+ (raw.value as Record<string, unknown>)[key] === undefined
122
+ );
123
+ }
124
+
99
125
  /** The configuration checked against its schema and assembled, or the failure that says why not. */
100
126
  assembled(): Promise<Loaded<Assembled>> {
101
127
  this.#assembled ??= this.#assemble();
@@ -19,6 +19,23 @@ const OFFICIAL_DIR = "official";
19
19
  /** The directory of the plugin `add package` writes around a Pi package. */
20
20
  const PACKAGE_DIR = "package";
21
21
 
22
+ /** The directory of each chat network's files that replace or add to the skeleton's, one directory per adapter. */
23
+ const ADAPTERS_DIR = "adapters";
24
+
25
+ /** The chat networks `init` can set a project up for; Discord's files are the skeleton itself. */
26
+ export const ADAPTERS = ["discord", "web"] as const;
27
+ export type Adapter = (typeof ADAPTERS)[number];
28
+
29
+ /** Whether `name` is an adapter `init` knows. */
30
+ export const isAdapter = (name: string): name is Adapter =>
31
+ (ADAPTERS as readonly string[]).includes(name);
32
+
33
+ /** Skeleton files a project for the adapter leaves out: a web project has no agents and no shared persona. */
34
+ const LEFT_OUT: Readonly<Record<Adapter, readonly string[]>> = {
35
+ discord: [],
36
+ web: ["agents.ts", "persona/shared.md"],
37
+ };
38
+
22
39
  /** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
23
40
  export const OFFICIAL_PLUGINS = [
24
41
  "codex-images",
@@ -143,31 +160,46 @@ export function renderPackagePlugin(
143
160
  }));
144
161
  }
145
162
 
146
- /** Every file of a new project: the skeleton and the `hello` plugin, paths relative to the project. */
163
+ /**
164
+ * Every file of a new project for `adapter` (default Discord): the skeleton, without the files the
165
+ * adapter leaves out and with its own files in place of the skeleton's, and the `hello` plugin;
166
+ * paths relative to the project.
167
+ */
147
168
  export function renderProject(
148
169
  substitutions: Substitutions,
149
170
  dir = TEMPLATES_DIR,
171
+ adapter: Adapter = "discord",
150
172
  ): Rendered[] {
151
173
  if (!existsSync(dir)) throw new Error(`templates are missing: ${dir}`);
152
174
  const values = {
153
175
  PROJECT: substitutions.project,
154
176
  VERSION: substitutions.version,
155
177
  };
178
+ const render = (base: string, path: string): Rendered => ({
179
+ path: targetOf(path),
180
+ content: fill(readFileSync(join(base, path), "utf8"), values),
181
+ });
182
+ const overlayDir = join(dir, ADAPTERS_DIR, adapter);
183
+ const overlay = existsSync(overlayDir)
184
+ ? filesUnder(overlayDir).map((file) =>
185
+ render(overlayDir, relative(overlayDir, file)),
186
+ )
187
+ : [];
188
+ const replaced = new Set([
189
+ ...LEFT_OUT[adapter],
190
+ ...overlay.map((file) => file.path),
191
+ ]);
156
192
  const skeleton = filesUnder(dir)
157
193
  .map((file) => relative(dir, file))
158
- .flatMap((path) =>
159
- [PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR].some((sub) =>
160
- path.startsWith(`${sub}/`),
161
- )
162
- ? []
163
- : [
164
- {
165
- path: targetOf(path),
166
- content: fill(readFileSync(join(dir, path), "utf8"), values),
167
- },
168
- ],
169
- );
170
- return [...skeleton, ...renderPlugin(pluginNames("hello"), dir)];
194
+ .filter(
195
+ (path) =>
196
+ ![PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR, ADAPTERS_DIR].some((sub) =>
197
+ path.startsWith(`${sub}/`),
198
+ ),
199
+ )
200
+ .map((path) => render(dir, path))
201
+ .filter((file) => !replaced.has(file.path));
202
+ return [...skeleton, ...overlay, ...renderPlugin(pluginNames("hello"), dir)];
171
203
  }
172
204
 
173
205
  /** Writes rendered files under `root`, creating directories; the caller has checked that none exists. */
@@ -25,6 +25,7 @@ import {
25
25
  } from "../testing/database.ts";
26
26
  import { useTestLocale } from "../testing/locale.ts";
27
27
  import { OWNER_SPEAKER, TEST_OWNER } from "../testing/owner.ts";
28
+ import { toolTiers } from "../tool-tiers.ts";
28
29
  import type {
29
30
  AgentCategory,
30
31
  AgentChannels,
@@ -176,6 +177,10 @@ export interface TeamFixture {
176
177
  skills: SkillRegistry;
177
178
  }
178
179
 
180
+ /** The tool tiers the team reads; `deploy` is an admin's tool, so an admin may approve it. */
181
+ const FIXTURE_TIERS = toolTiers();
182
+ FIXTURE_TIERS.declare("test", { deploy: "admin" });
183
+
179
184
  /** The fixture of the running test; set by `useTeamFixture` before each test. */
180
185
  export const fx = {} as TeamFixture;
181
186
 
@@ -297,6 +302,7 @@ export function useTeamFixture(): void {
297
302
  eventLog.length = 0;
298
303
  team = new AgentTeam({
299
304
  owner: TEST_OWNER,
305
+ toolTiers: FIXTURE_TIERS,
300
306
  shellUser: "mcops",
301
307
  guildId: "1",
302
308
  entryChannelId: ENTRY,
@@ -143,8 +143,13 @@ export class TeamTurns {
143
143
  );
144
144
  }
145
145
 
146
- /** Whether the speaker's tier holds every tool of the held actions. */
146
+ /**
147
+ * Whether the speaker may approve the held actions: the owner, or the speaker whose turn held
148
+ * them, when their tier now holds every tool. Nobody else in the channel may.
149
+ */
147
150
  #mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
151
+ if (speaker.tier !== "owner" && speaker.id !== pending.speakerId)
152
+ return false;
148
153
  const tiers = this.#options.toolTiers ?? toolTiers();
149
154
  return pending.calls.every(
150
155
  (call) =>