pi-roundtable 0.6.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0] - 2026-10-02
9
+
10
+ ### Added
11
+
12
+ - Turn reply attachments: `ToolTurn.attachFile(file)` queues raw bytes with the agent's reply instead of posting as the bot ahead of it.
13
+ The main entry exports `ReplyFile` (`{ name, data: Uint8Array }`), `attachReplyFile` (`(file): void`) for raw session and Pi package tools, `ReplyFileError`, and frozen `REPLY_FILE_LIMITS` (10 files, 10 MiB each, 50 MiB total per turn).
14
+ Successful `TurnResult` may carry `files`; a successful textless Pi answer with files is posted, while stopped or failed turns discard them.
15
+ Discord sends files through the same agent webhook identity as the text.
16
+ Transient tasks and calls outside a conversation turn refuse attachments.
17
+ - `testPlugin` records files attached by `runTool` as `files: { channel, file }[]`; inject a file-capable surface for attachment tests.
18
+ The guide includes the tested `examples/reply-files.ts` plugin.
19
+ - `roundtable add plugin release-notice` copies a third official plugin. After the agent server is up it posts once in the coordinator's channel when the running release differs from the one last announced, with the commits the release added since the release last announced, read from a `release.json` that the deploy writes (`{ "sha": "...", "commits": ["subject", ...] }`); it also names the channels whose work the previous shutdown cut short, recorded from the `shutdown` event. It remembers the announced `sha` only after the post succeeds. `createReleaseNotice` takes `releaseFile`, `dataDir`, and `announce` to change where the release is read, where the state is kept, and where the post goes.
20
+
21
+ ### Changed
22
+
23
+ - Chat surfaces explicitly opt in with `ChatSurface.supportsFiles: true` and must deliver every reply file or reject.
24
+ Existing custom surfaces that handle files must add this flag; absent or false refuses attachment calls and direct `SurfacePort.sendReply` file sends instead of silently losing them.
25
+ Replacement-runtime result files pass the same capability and size checks; custom turn reply callbacks receive the files and own delivery.
26
+ - `release-notice` joins `codex-images` and `dice` as a reserved name for `add plugin`, and the usage text lists all three.
27
+
28
+ ## [0.6.1] - 2026-10-02
29
+
30
+ ### Fixed
31
+
32
+ - `add plugin` and `add package` kept a one-line `plugins` list on one line however long it grew, so a project's `biome check` failed once the line passed 80 columns. A list that would pass 80 columns is now put one element a line, as the formatter writes it; a list already over several lines keeps its layout, and one holding a comment stays as it was.
33
+
8
34
  ## [0.6.0] - 2026-10-02
9
35
 
10
36
  ### Added
package/docs/plugins.md CHANGED
@@ -65,7 +65,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
65
65
 
66
66
  `roundtable add plugin <name>` creates `plugins/<name>.ts` and its test from a small template and lists it in `roundtable.config.ts`.
67
67
  The name is lowercase words joined by dashes, such as `my-notes`.
68
- Two names are reserved for the [official plugins](#official-plugins): `codex-images` and `dice` copy a ready-made plugin into the project.
68
+ Three names are reserved for the [official plugins](#official-plugins): `codex-images`, `dice`, and `release-notice` copy a ready-made plugin into the project.
69
69
 
70
70
  `roundtable add package <spec>` adds a Pi package from npm: it runs `bun add <spec>`, loads the package's extensions to find the tools they register, and writes `plugins/<name>.ts` and its test, named after the package without its scope, as [`piPackages`](#pipackages-pi-extensions-every-session-loads) describes.
71
71
 
@@ -338,7 +338,7 @@ A key the contract does not have stops the start and names the closest one.
338
338
  ### `tools`: what agents can call
339
339
 
340
340
  `defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
341
- `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, and an abort signal.
341
+ `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, and `attachFile`.
342
342
  It returns the text the model reads.
343
343
  Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
344
344
 
@@ -377,6 +377,72 @@ export const notes = definePlugin({
377
377
 
378
378
  The tool names `bash`, `read`, `edit`, and `write` belong to the agents already.
379
379
 
380
+ #### Attach files to the agent's reply
381
+
382
+ Call `turn.attachFile(file)` from a tool's `run` to queue a file for the turn's reply, and still return the text the model reads.
383
+ A `ReplyFile` is `{ name: string; data: Uint8Array }`: raw bytes, including images, not a path or base64 string.
384
+ The core copies the bytes when the call succeeds, so the tool may reuse its buffer afterwards.
385
+ The tool does not send a message itself; the successful turn returns `TurnResult.files` and the reply path hands them to the surface as `OutboundReply.files`.
386
+ On Discord, files follow the text as one file per message to avoid image grids, all through the same agent webhook name and avatar as the text.
387
+ Other surfaces may send the text and files in one message or a message group.
388
+
389
+ <!-- example: examples/reply-files.ts -->
390
+ ```ts
391
+ import { definePlugin, defineTool } from "pi-roundtable";
392
+ import { Type } from "typebox";
393
+
394
+ /** A small PNG keeps this example runnable without an image provider. */
395
+ export const imageReply = definePlugin({
396
+ name: "image-reply",
397
+ setup: () => ({
398
+ tools: [
399
+ defineTool({
400
+ name: "reply_image",
401
+ description: "Attach a sample image to your reply.",
402
+ parameters: Type.Object({}),
403
+ minTier: "member",
404
+ run: (_args, turn) => {
405
+ turn.attachFile({
406
+ name: "sample.png",
407
+ data: Buffer.from(
408
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=",
409
+ "base64",
410
+ ),
411
+ });
412
+ return "The sample image is attached to this turn's reply.";
413
+ },
414
+ }),
415
+ ],
416
+ }),
417
+ });
418
+ ```
419
+ <!-- /example -->
420
+
421
+ `attachReplyFile(file): void`, `ReplyFile`, `ReplyFileError`, and the frozen `REPLY_FILE_LIMITS` are exported from `pi-roundtable`.
422
+ Raw `sessionTools` and Pi package tools may import and call `attachReplyFile` during their awaited tool execution; it is the same function as `turn.attachFile`.
423
+ It uses the current asynchronous turn, not a global channel queue, so simultaneous turns and nested conversation turns keep separate files.
424
+ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundle another copy of it.
425
+ Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
426
+ Calling the helper outside `context.turns.run` or an agent-team turn, or after that turn finishes, throws `ReplyFileError`; direct standalone runtime calls have no reply collector.
427
+ Await work that produces files before returning from the tool.
428
+
429
+ | Case | Behavior |
430
+ |---|---|
431
+ | Successful final answer with no text | Files are posted without a placeholder text; a final answer with neither text nor files still fails in the Pi runtime |
432
+ | Stopped, timed-out, or failed turn | Accepted files are discarded; only the usual stopped or failure notice is posted |
433
+ | A tool fails but the model recovers and completes the turn | Files already accepted remain queued for the successful reply; a tool failure does not roll back earlier attachment calls |
434
+ | Unsupported surface | `ChatSurface.supportsFiles` must be `true`; absent or false makes attachment calls throw `ReplyFileError`, which Pi reports to the model as a tool error |
435
+ | Too many or too large files | At most 10 files per turn, 10 MiB (10,485,760 bytes) per file, and 50 MiB (52,428,800 bytes) in total across all tools; the exceeding call throws `ReplyFileError` without queuing that file |
436
+ | Invalid file | Empty bytes, non-`Uint8Array` data, empty filenames, names over 255 characters, paths, `.`/`..`, or control characters throw `ReplyFileError` |
437
+ | A surface rejects delivery | The reply path logs `reply not posted` or `agent reply not posted`; delivery may be partial, is not retried, and does not change the completed runtime result |
438
+
439
+ A surface declaring `supportsFiles: true` must deliver every `OutboundReply.files` entry with the reply's speaker identity, or reject with an error; its transport may impose stricter limits.
440
+ `SurfacePort.sendReply` also refuses direct file sends to a surface without that declaration instead of silently dropping files.
441
+ A replacement runtime may return `files` in its successful `TurnResult`; the turn path applies the same limits and capability check before posting.
442
+ Use either the helper or the result for each file: returning an already attached file queues a second copy, counted against the same limits.
443
+ A custom `ConversationTurnInput.reply(result)` receives `result.files` and owns their delivery instead of the default surface reply.
444
+ Test an attachment tool with an injected file-capable surface and inspect `harness.files` (see [`reply-files.test.ts`](../examples/reply-files.test.ts)); `runTool` itself posts nothing.
445
+
380
446
  ### `holdRules`, and a tool's `hold`: calls that wait for the owner
381
447
 
382
448
  A held call is described to the owner, who approves or refuses it in Discord before the call runs.
@@ -1811,10 +1877,10 @@ The following fields and parts from 0.1.0 were removed because only built-in plu
1811
1877
 
1812
1878
  ## Official plugins
1813
1879
 
1814
- The package ships two plugins you can copy into a project and change.
1815
- `roundtable add plugin codex-images` and `roundtable add plugin dice` write `plugins/<name>.ts` and `plugins/<name>.test.ts`, import the plugin in `roundtable.config.ts`, and list it in `plugins`.
1880
+ The package ships three plugins you can copy into a project and change.
1881
+ `roundtable add plugin codex-images`, `roundtable add plugin dice`, and `roundtable add plugin release-notice` write `plugins/<name>.ts` and `plugins/<name>.test.ts`, import the plugin in `roundtable.config.ts`, and list it in `plugins`.
1816
1882
  You can edit the copied files; `add plugin` refuses to overwrite existing ones.
1817
- Both names are reserved for these copies.
1883
+ The three names are reserved for these copies.
1818
1884
 
1819
1885
  A separate package, [pi-roundtable-mcp](https://www.npmjs.com/package/pi-roundtable-mcp), adds two more plugins, `mcpConnectors` and `remoteMcp`.
1820
1886
  The first lets the owner add MCP servers such as Notion or a calendar from Discord and gives your code the list; the second lets an agent outside Discord reach your agent.
@@ -1824,14 +1890,44 @@ Install it with `bun add pi-roundtable-mcp`.
1824
1890
  |---|---|---|
1825
1891
  | `codex-images` | Fills the [`images` slot](#providers-replace-a-part-the-core-runs-on), so agents can draw avatars from a prompt and reference pictures | A login to the `openai-codex` provider; setup throws a `PluginError` that says so when the host has none |
1826
1892
  | `dice` | Adds the `roll_dice` tool for members: `2d6+3`, `4d6k3` (keep or drop the highest or lowest dice), several groups, and fate dice `dF`, answered as text such as `2d6+3: [3, 5] + 3 = 11` | Nothing; it takes at most 100 dice in all and 1000 sides per die |
1893
+ | `release-notice` | After a deploy, posts once in the coordinator's channel which commits the running release added since the release last announced, and says which channels the previous shutdown cut short | A `release.json` that your deploy writes (see below); without the file it posts nothing |
1827
1894
 
1828
1895
  `codex-images` takes the login through [`context.apiKey("openai-codex")`](#the-context).
1829
1896
  It sends requests to ChatGPT's Codex backend using the owner's ChatGPT subscription login.
1830
1897
  OpenAI doesn't document the backend for this use, so it can stop working without notice; the subscription's terms apply.
1831
1898
  The copied file begins with this warning.
1832
1899
 
1833
- Each copy has a test that runs offline: `codex-images` with a fake `fetch`, `dice` with a fake random source.
1834
- Both files export a `create...` function (`createCodexImages`, `createDice`) that takes the part a test replaces, and the plugin you list in the config, built from it.
1900
+ `release-notice` listens to two [events](#events-hear-what-the-core-does).
1901
+ When the agent server's team service reports `ready`, it reads the release file and posts if the release's `sha` differs from the one it last announced (`announced-release` in the data directory), or if the previous shutdown cut work short.
1902
+ It records the announced `sha` only after the post succeeds, so a failed post is tried again at the next start, and a `release.json` that is not valid (not JSON, an empty `sha`, or `commits` that is not a list of strings) fails the handler with its path in the log.
1903
+ At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on after an hour, to `aborted-on-shutdown.json` in the data directory; the next start's announcement names them as cut short (a Discord channel key as a `<#id>` mention), and once it is posted they are removed from the file. A shutdown that arrives while a post is still in flight keeps its channels for the next announcement.
1904
+ A plain restart of the same release with nothing cut short posts nothing.
1905
+
1906
+ The deploy writes `release.json` into the release directory, where the bot runs, before it starts the bot.
1907
+ The file holds the commit the release was built from and the subjects of the commits it added over the release last announced, newest first, so that a release whose announcement failed or never ran is not skipped over:
1908
+
1909
+ ```json
1910
+ { "sha": "abc1234", "commits": ["fix: handle an empty reply", "feat: add a notes tool"] }
1911
+ ```
1912
+
1913
+ The plugin keeps the sha it last announced in `announced-release` in its data directory, so a deploy script on the same host can read the range's start from there.
1914
+ This script writes the file from the checkout, and stops the deploy when `git log` or `jq` fails; the first deploy, with nothing announced yet, gets an empty `commits` list:
1915
+
1916
+ ```sh
1917
+ set -euo pipefail
1918
+ previous=$(cat data/announced-release 2>/dev/null || true)
1919
+ commits='[]'
1920
+ if [ -n "$previous" ]; then
1921
+ commits=$(git log --format=%s "$previous..HEAD" | jq -R . | jq -s .)
1922
+ fi
1923
+ printf '{"sha":"%s","commits":%s}\n' "$(git rev-parse --short HEAD)" "$commits" > release.json
1924
+ ```
1925
+
1926
+ `createReleaseNotice(options)` takes `releaseFile` (default `release.json` in the working directory), `dataDir` (default `./data`, where a new project's config keeps its data, so set it when the config's `dataDir` differs), and `announce` (default: the agent team's `announce`, which posts in the coordinator's channel and splits a long text).
1927
+ The plugin you list in the config, `releaseNotice`, uses the defaults.
1928
+
1929
+ Each copy has a test that runs offline: `codex-images` with a fake `fetch`, `dice` with a fake random source, `release-notice` with temporary directories and its own `announce`.
1930
+ Each file exports a `create...` function (`createCodexImages`, `createDice`, `createReleaseNotice`) that takes the part a test replaces, and the plugin you list in the config, built from it.
1835
1931
 
1836
1932
  ## Testing a plugin
1837
1933
 
@@ -1847,6 +1943,7 @@ It returns:
1847
1943
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
1848
1944
  | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace? })` returns the description of a call that must be approved first, or `undefined` |
1849
1945
  | `runTool(name, args, { speaker, channel }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads |
1946
+ | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
1850
1947
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
1851
1948
  | `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
1852
1949
  | `runtime` | The runtime the plugin's `runtime` provider built, given stand-in dependencies; `undefined` when it fills no such slot |
@@ -2283,6 +2380,9 @@ Import from the entries listed below; source area files are internal.
2283
2380
  | `ResolvedProviders` | `pi-roundtable` | type |
2284
2381
  | `ResolvedSkill` | `pi-roundtable` | type |
2285
2382
  | `Roundtable` | `pi-roundtable` | value |
2383
+ | `ReplyFile` | `pi-roundtable` | type |
2384
+ | `ReplyFileError` | `pi-roundtable` | value |
2385
+ | `REPLY_FILE_LIMITS` | `pi-roundtable` | value |
2286
2386
  | `RoundtableConfig` | `pi-roundtable` | type |
2287
2387
  | `RoundtableOptions` | `pi-roundtable` | type |
2288
2388
  | `RoundtablePlugin` | `pi-roundtable` | type |
@@ -2336,6 +2436,7 @@ Import from the entries listed below; source area files are internal.
2336
2436
  | `TurnResult` | `pi-roundtable` | type |
2337
2437
  | `TurnSelection` | `pi-roundtable` | type |
2338
2438
  | `Weekday` | `pi-roundtable` | type |
2439
+ | `attachReplyFile` | `pi-roundtable` | value |
2339
2440
  | `channelKey` | `pi-roundtable` | value |
2340
2441
  | `definePlugin` | `pi-roundtable` | value |
2341
2442
  | `defineRoundtable` | `pi-roundtable` | value |
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { ChatSurface } from "pi-roundtable";
3
+ import { testPlugin } from "pi-roundtable/testing";
4
+ import { imageReply } from "./reply-files.ts";
5
+
6
+ test("reply_image records its attachment without posting ahead of the agent", async () => {
7
+ let posted = false;
8
+ const surface: ChatSurface = {
9
+ surface: "fake",
10
+ supportsFiles: true,
11
+ start: async () => undefined,
12
+ sendReply: async () => {
13
+ posted = true;
14
+ },
15
+ };
16
+ const harness = await testPlugin(imageReply, { surfaces: [surface] });
17
+ try {
18
+ expect(
19
+ await harness.runTool("reply_image", {}, { channel: "fake:room" }),
20
+ ).toBe("The sample image is attached to this turn's reply.");
21
+ expect(posted).toBe(false);
22
+ expect(harness.files).toHaveLength(1);
23
+ expect(harness.files[0]?.channel).toBe("fake:room");
24
+ expect(harness.files[0]?.file.name).toBe("sample.png");
25
+ expect(harness.files[0]?.file.data.slice(0, 8)).toEqual(
26
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
27
+ );
28
+ await expect(harness.runTool("reply_image", {})).rejects.toThrow(
29
+ "does not support reply files",
30
+ );
31
+ } finally {
32
+ await harness.stop();
33
+ }
34
+ });
@@ -0,0 +1,27 @@
1
+ import { definePlugin, defineTool } from "pi-roundtable";
2
+ import { Type } from "typebox";
3
+
4
+ /** A small PNG keeps this example runnable without an image provider. */
5
+ export const imageReply = definePlugin({
6
+ name: "image-reply",
7
+ setup: () => ({
8
+ tools: [
9
+ defineTool({
10
+ name: "reply_image",
11
+ description: "Attach a sample image to your reply.",
12
+ parameters: Type.Object({}),
13
+ minTier: "member",
14
+ run: (_args, turn) => {
15
+ turn.attachFile({
16
+ name: "sample.png",
17
+ data: Buffer.from(
18
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=",
19
+ "base64",
20
+ ),
21
+ });
22
+ return "The sample image is attached to this turn's reply.";
23
+ },
24
+ }),
25
+ ],
26
+ }),
27
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/cli.ts CHANGED
@@ -36,7 +36,7 @@ const USAGE = `roundtable: a Discord agent server on Pi
36
36
  roundtable doctor [--reachable] check the setup and say how to fix what is wrong
37
37
  roundtable start run the checks that need no network, then the bot
38
38
  roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
39
- the names ${OFFICIAL_PLUGINS.join(" and ")} are reserved for the official plugins,
39
+ the names ${new Intl.ListFormat("en").format(OFFICIAL_PLUGINS)} are reserved for the official plugins,
40
40
  which are copied in ready to run instead of the template
41
41
  roundtable add package <spec> install a Pi package with bun add, and add plugins/<name>.ts that
42
42
  loads it and gives its tools to every agent turn
@@ -115,6 +115,50 @@ function pluginList(object: Node): Node | undefined {
115
115
  interface Insertion {
116
116
  at: number;
117
117
  text: string;
118
+ /** How many characters from `at` the text replaces; none when it only inserts. */
119
+ replaces?: number;
120
+ }
121
+
122
+ /** The width the formatter gives a line: a tab counts as its default indent width of 2. */
123
+ const LINE_WIDTH = 80;
124
+ const width = (line: string): number => line.replaceAll("\t", " ").length;
125
+
126
+ /**
127
+ * The one-line list with `ident` added, put one element a line when the line it sits on would
128
+ * pass the formatter's width, as the project's formatter would; undefined when the list already
129
+ * spans lines, holds anything besides its elements and commas, or still fits.
130
+ */
131
+ function expandedList(
132
+ source: string,
133
+ list: Node,
134
+ elements: readonly Node[],
135
+ ident: string,
136
+ ): Insertion | undefined {
137
+ const inner = source.slice(list.start + 1, list.end - 1);
138
+ if (inner.includes("\n")) return undefined;
139
+ const items = elements.map((element) =>
140
+ source.slice(element.start, element.end),
141
+ );
142
+ // The text around and between the elements, which must be only commas and spaces.
143
+ const edges = [
144
+ list.start + 1,
145
+ ...elements.flatMap((element) => [element.start, element.end]),
146
+ list.end - 1,
147
+ ];
148
+ let gaps = "";
149
+ for (let index = 0; index < edges.length; index += 2)
150
+ gaps += source.slice(edges[index], edges[index + 1]);
151
+ if (!/^[\s,]*$/.test(gaps)) return undefined;
152
+ const lineStart = source.lastIndexOf("\n", list.start) + 1;
153
+ const lineEnd = source.indexOf("\n", list.end);
154
+ const line = `${source.slice(lineStart, list.start)}[${[...items, ident].join(", ")}]${source.slice(list.end, lineEnd === -1 ? undefined : lineEnd)}`;
155
+ if (width(line) <= LINE_WIDTH) return undefined;
156
+ const indent = /^[ \t]*/.exec(source.slice(lineStart, list.start))?.[0] ?? "";
157
+ return {
158
+ at: list.start,
159
+ replaces: list.end - list.start,
160
+ text: `[\n${[...items, ident].map((item) => `${indent}\t${item},\n`).join("")}${indent}]`,
161
+ };
118
162
  }
119
163
 
120
164
  /** The text to add so `ident` joins the list, in the list's own layout. */
@@ -122,6 +166,8 @@ function listInsertion(source: string, list: Node, ident: string): Insertion {
122
166
  const elements = (list.elements as (Node | null)[]).filter(
123
167
  (element): element is Node => element !== null,
124
168
  );
169
+ const expanded = expandedList(source, list, elements, ident);
170
+ if (expanded) return expanded;
125
171
  const last = elements.at(-1);
126
172
  if (!last) return { at: list.start + 1, text: ident };
127
173
  const between = source.slice(last.end, list.end - 1);
@@ -161,8 +207,9 @@ function importInsertion(
161
207
 
162
208
  /**
163
209
  * Adds the plugin's import line and lists it in the `plugins` array of the default export, in the
164
- * file's own layout. It refuses, changing nothing, a file that does not parse, a default export
165
- * with no `plugins` list it can find, and a name already bound.
210
+ * file's own layout; a one-line list that would pass 80 columns is put one element a line. It
211
+ * refuses, changing nothing, a file that does not parse, a default export with no `plugins` list
212
+ * it can find, and a name already bound.
166
213
  */
167
214
  export function addPluginToConfig(
168
215
  source: string,
@@ -192,7 +239,10 @@ export function addPluginToConfig(
192
239
  ].sort((a, b) => b.at - a.at);
193
240
  let edited = source;
194
241
  for (const edit of edits)
195
- edited = edited.slice(0, edit.at) + edit.text + edited.slice(edit.at);
242
+ edited =
243
+ edited.slice(0, edit.at) +
244
+ edit.text +
245
+ edited.slice(edit.at + (edit.replaces ?? 0));
196
246
  // A result that does not parse is a bug here; never write it.
197
247
  parseModule(edited, file);
198
248
  return edited;
@@ -20,7 +20,11 @@ const OFFICIAL_DIR = "official";
20
20
  const PACKAGE_DIR = "package";
21
21
 
22
22
  /** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
23
- export const OFFICIAL_PLUGINS = ["codex-images", "dice"] as const;
23
+ export const OFFICIAL_PLUGINS = [
24
+ "codex-images",
25
+ "dice",
26
+ "release-notice",
27
+ ] as const;
24
28
 
25
29
  /** Whether `name` is one of the official plugins. */
26
30
  export const isOfficialPlugin = (name: string): boolean =>
@@ -2,6 +2,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
2
2
  import type {
3
3
  ChannelKey,
4
4
  PendingConfirmation,
5
+ ReplyFile,
5
6
  TurnResult,
6
7
  } from "../domain/conversation.ts";
7
8
  import { AgentError } from "../domain/errors.ts";
@@ -9,6 +10,7 @@ import type { AgentTurnScope } from "../domain/ports.ts";
9
10
  import { messages } from "../i18n/index.ts";
10
11
  import { splitReply } from "../presentation/reply-splitter.ts";
11
12
  import { thinkingLine } from "../presentation/thinking-line.ts";
13
+ import { withReplyFiles } from "../reply-files.ts";
12
14
  import { endOf, settleTurn } from "../routing/settle-turn.ts";
13
15
  import type { Speaker } from "../speakers.ts";
14
16
  import { toolTiers } from "../tool-tiers.ts";
@@ -183,17 +185,19 @@ export class TeamTurns {
183
185
  try {
184
186
  result = await settleTurn(
185
187
  () =>
186
- this.#options.runtime().runTurn({
187
- channel: postTo,
188
- selection: this.#options.selection(scope),
189
- text,
190
- ...(extra.attachments ? { attachments: extra.attachments } : {}),
191
- ...(extra.confirmed ? { confirmed: true } : {}),
192
- ...(extra.steerable ? { steerable: true } : {}),
193
- ...(extra.interactive ? { interactive: true } : {}),
194
- agent: scope,
195
- speaker: chain.speaker,
196
- }),
188
+ withReplyFiles(true, () =>
189
+ this.#options.runtime().runTurn({
190
+ channel: postTo,
191
+ selection: this.#options.selection(scope),
192
+ text,
193
+ ...(extra.attachments ? { attachments: extra.attachments } : {}),
194
+ ...(extra.confirmed ? { confirmed: true } : {}),
195
+ ...(extra.steerable ? { steerable: true } : {}),
196
+ ...(extra.interactive ? { interactive: true } : {}),
197
+ agent: scope,
198
+ speaker: chain.speaker,
199
+ }),
200
+ ),
197
201
  "agent turn",
198
202
  );
199
203
  } finally {
@@ -220,6 +224,7 @@ export class TeamTurns {
220
224
  await this.#post(postTo, this.#current(agent), {
221
225
  ...(thinking ? { thinking } : {}),
222
226
  chunks: result.ok ? splitReply(result.text) : [noticeOf(result)],
227
+ ...(result.ok && result.files?.length ? { files: result.files } : {}),
223
228
  });
224
229
  } catch (error) {
225
230
  logger.error({ agent: agent.name, err: error }, "agent reply not posted");
@@ -248,7 +253,12 @@ export class TeamTurns {
248
253
  async #post(
249
254
  channel: ChannelKey,
250
255
  as: Agent,
251
- body: { thinking?: string; chunks: string[]; threadId?: string },
256
+ body: {
257
+ thinking?: string;
258
+ chunks: string[];
259
+ files?: ReplyFile[];
260
+ threadId?: string;
261
+ },
252
262
  ): Promise<void> {
253
263
  const { store, channels, studio, logger } = this.#options;
254
264
  if (!channelOwner(store, channel)) {
@@ -39,6 +39,8 @@ export function channelKey(surface: string, id: string): ChannelKey {
39
39
  export interface ChatSurface {
40
40
  /** The key prefix of this surface's channels, such as "discord"; unique per host. */
41
41
  readonly surface: string;
42
+ /** Explicit opt-in to delivering OutboundReply.files; absent or false refuses turn attachments. */
43
+ readonly supportsFiles?: boolean;
42
44
  /**
43
45
  * Connects and delivers every incoming message; the host passes its conversation router. A
44
46
  * message whose channel key has another prefix is logged and dropped.
@@ -1,7 +1,9 @@
1
1
  import type { Static, TObject } from "typebox";
2
+ import type { ReplyFile } from "./domain/conversation.ts";
2
3
  import { PluginError } from "./errors.ts";
3
4
  import type { HoldRule } from "./holds.ts";
4
5
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
6
+ import { attachReplyFile } from "./reply-files.ts";
5
7
  import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
6
8
  import { toolError, toolText } from "./shared/tool-result.ts";
7
9
  import { type Speaker, TIERS, type Tier } from "./speakers.ts";
@@ -22,6 +24,8 @@ export interface ToolTurn {
22
24
  /** The agent whose turn it is. */
23
25
  agent: AgentTurnScope | undefined;
24
26
  signal: AbortSignal | undefined;
27
+ /** Queues a file for this turn's successful reply; throws ReplyFileError when refused. */
28
+ attachFile(file: ReplyFile): void;
25
29
  }
26
30
 
27
31
  /**
@@ -108,6 +112,7 @@ export function defineTool<Schema extends TObject>(
108
112
  channel: context.turnChannel,
109
113
  agent: context.agent,
110
114
  signal,
115
+ attachFile: attachReplyFile,
111
116
  }),
112
117
  );
113
118
  } catch (error) {
@@ -64,6 +64,7 @@ export class DiscordSurface
64
64
  implements ChatSurface, OwnerNotifier, DiscordConnection
65
65
  {
66
66
  readonly surface = "discord";
67
+ readonly supportsFiles = true;
67
68
  readonly #options: DiscordSurfaceOptions;
68
69
  readonly #client = new Client({
69
70
  intents: [
@@ -25,8 +25,14 @@ export interface PendingConfirmation {
25
25
  calls: HeldCall[];
26
26
  }
27
27
 
28
+ /** A file produced for a reply; data is raw bytes, not a path or base64. */
29
+ export interface ReplyFile {
30
+ name: string;
31
+ data: Uint8Array;
32
+ }
33
+
28
34
  export type TurnResult =
29
- | { ok: true; text: string; thinking?: string }
35
+ | { ok: true; text: string; thinking?: string; files?: ReplyFile[] }
30
36
  | { ok: false; error: Error; stopped?: true };
31
37
 
32
38
  export interface OutboundReply {
@@ -37,5 +43,5 @@ export interface OutboundReply {
37
43
  /** Message texts in posting order; the card is posted before them. */
38
44
  chunks: string[];
39
45
  /** Files the run produced, posted after the text. */
40
- files?: { name: string; data: Uint8Array }[];
46
+ files?: ReplyFile[];
41
47
  }
@@ -0,0 +1,107 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { ReplyFile, TurnResult } from "./domain/conversation.ts";
3
+
4
+ /** Limits across all tools in one turn, independent of the surface's own limits. */
5
+ export const REPLY_FILE_LIMITS = Object.freeze({
6
+ maxFiles: 10,
7
+ maxFileBytes: 10 * 1024 * 1024,
8
+ maxTotalBytes: 50 * 1024 * 1024,
9
+ });
10
+
11
+ /** An attachment was refused; its message names the reason instead of dropping the file. */
12
+ export class ReplyFileError extends Error {
13
+ override name = "ReplyFileError";
14
+ }
15
+
16
+ interface ReplyFiles {
17
+ active: boolean;
18
+ supported: boolean;
19
+ files: ReplyFile[];
20
+ bytes: number;
21
+ }
22
+
23
+ const current = new AsyncLocalStorage<ReplyFiles | undefined>();
24
+
25
+ /**
26
+ * Queues a copy of a file for the current turn's successful reply. Session tools and Pi package
27
+ * tools may call this directly; outside a supported running turn it throws ReplyFileError.
28
+ */
29
+ export function attachReplyFile(file: ReplyFile): void {
30
+ const turn = current.getStore();
31
+ if (!turn?.active)
32
+ throw new ReplyFileError(
33
+ "attachReplyFile requires a running conversation turn; await it inside the turn's tool call.",
34
+ );
35
+ if (!turn.supported)
36
+ throw new ReplyFileError(
37
+ "this turn's chat surface does not support reply files.",
38
+ );
39
+ if (
40
+ !file ||
41
+ typeof file.name !== "string" ||
42
+ !file.name.trim() ||
43
+ file.name.length > 255 ||
44
+ /[\\/]/.test(file.name) ||
45
+ [...file.name].some(
46
+ (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
47
+ ) ||
48
+ file.name === "." ||
49
+ file.name === ".."
50
+ )
51
+ throw new ReplyFileError(
52
+ "a reply file needs a non-empty filename of at most 255 characters, without paths or control characters.",
53
+ );
54
+ if (!(file.data instanceof Uint8Array) || file.data.byteLength === 0)
55
+ throw new ReplyFileError("a reply file needs non-empty Uint8Array data.");
56
+ if (turn.files.length >= REPLY_FILE_LIMITS.maxFiles)
57
+ throw new ReplyFileError(
58
+ `a turn may attach at most ${REPLY_FILE_LIMITS.maxFiles} reply files.`,
59
+ );
60
+ if (file.data.byteLength > REPLY_FILE_LIMITS.maxFileBytes)
61
+ throw new ReplyFileError(
62
+ `a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
63
+ );
64
+ if (turn.bytes + file.data.byteLength > REPLY_FILE_LIMITS.maxTotalBytes)
65
+ throw new ReplyFileError(
66
+ `a turn's reply files may contain at most ${REPLY_FILE_LIMITS.maxTotalBytes} bytes in total.`,
67
+ );
68
+ turn.files.push({ name: file.name, data: new Uint8Array(file.data) });
69
+ turn.bytes += file.data.byteLength;
70
+ }
71
+
72
+ /** Used by test fixtures to forward attachments only when called inside a turn. */
73
+ export function hasReplyFileScope(): boolean {
74
+ return current.getStore()?.active === true;
75
+ }
76
+
77
+ /** Whether an otherwise empty final answer has files to show. */
78
+ export function hasReplyFiles(): boolean {
79
+ const turn = current.getStore();
80
+ return turn?.active === true && turn.files.length > 0;
81
+ }
82
+
83
+ /** One isolated collector per turn, also when turns run concurrently or nest. */
84
+ export async function withReplyFiles(
85
+ supported: boolean,
86
+ run: () => Promise<TurnResult>,
87
+ ): Promise<TurnResult> {
88
+ const turn: ReplyFiles = { active: true, supported, files: [], bytes: 0 };
89
+ return current.run(turn, async () => {
90
+ try {
91
+ const result = await run();
92
+ if (!result.ok) return result;
93
+ // A replacement runtime may produce files in its result rather than calling the helper.
94
+ for (const file of result.files ?? []) attachReplyFile(file);
95
+ return turn.files.length ? { ...result, files: [...turn.files] } : result;
96
+ } finally {
97
+ turn.active = false;
98
+ turn.files = [];
99
+ turn.bytes = 0;
100
+ }
101
+ });
102
+ }
103
+
104
+ /** A transient task reports text, not a conversation reply: it cannot borrow its parent's collector. */
105
+ export function withoutReplyFiles<T>(run: () => Promise<T>): Promise<T> {
106
+ return current.run(undefined, run);
107
+ }
@@ -7,6 +7,7 @@ import type { Logger } from "../log.ts";
7
7
  import type { EventSink } from "../plugin.ts";
8
8
  import { splitReply } from "../presentation/reply-splitter.ts";
9
9
  import { thinkingLine } from "../presentation/thinking-line.ts";
10
+ import { withReplyFiles } from "../reply-files.ts";
10
11
  import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
11
12
  import type { Speaker } from "../speakers.ts";
12
13
  import { endOf, settleTurn } from "./settle-turn.ts";
@@ -85,20 +86,24 @@ export function conversationTurns(
85
86
  try {
86
87
  result = await settleTurn(
87
88
  () =>
88
- runtime.runTurn({
89
- channel,
90
- kind,
91
- selection: input.selection ?? {
92
- id: DEFAULT_SELECTION,
93
- ...options.selection(),
94
- },
95
- text: input.text,
96
- speaker,
97
- ...(input.attachments ? { attachments: input.attachments } : {}),
98
- ...(input.confirmed ? { confirmed: true } : {}),
99
- ...(input.steerable ? { steerable: true } : {}),
100
- ...(input.interactive ? { interactive: true } : {}),
101
- }),
89
+ withReplyFiles(surfaces.of(channel)?.supportsFiles === true, () =>
90
+ runtime.runTurn({
91
+ channel,
92
+ kind,
93
+ selection: input.selection ?? {
94
+ id: DEFAULT_SELECTION,
95
+ ...options.selection(),
96
+ },
97
+ text: input.text,
98
+ speaker,
99
+ ...(input.attachments
100
+ ? { attachments: input.attachments }
101
+ : {}),
102
+ ...(input.confirmed ? { confirmed: true } : {}),
103
+ ...(input.steerable ? { steerable: true } : {}),
104
+ ...(input.interactive ? { interactive: true } : {}),
105
+ }),
106
+ ),
102
107
  "conversation turn",
103
108
  );
104
109
  } finally {
@@ -135,5 +140,6 @@ function replyOf(result: TurnResult) {
135
140
  return {
136
141
  ...(thinking ? { thinking } : {}),
137
142
  chunks: splitReply(result.text),
143
+ ...(result.files?.length ? { files: result.files } : {}),
138
144
  };
139
145
  }
@@ -24,8 +24,14 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
24
24
  };
25
25
  return {
26
26
  of,
27
- sendReply: async (channel, reply) =>
28
- served(channel).sendReply(channel, reply),
27
+ sendReply: async (channel, reply) => {
28
+ const surface = served(channel);
29
+ if (reply.files?.length && surface.supportsFiles !== true)
30
+ throw new PluginError(
31
+ `surface ${surface.surface} does not support reply files; declare supportsFiles: true and deliver every file in sendReply.`,
32
+ );
33
+ await surface.sendReply(channel, reply);
34
+ },
29
35
  startTyping: (channel) =>
30
36
  of(channel)?.startTyping?.(channel) ?? (() => undefined),
31
37
  showStop: (channel) =>
@@ -24,6 +24,7 @@ import {
24
24
  type ThinkingLevel,
25
25
  type ThinkingSetting,
26
26
  } from "../models.ts";
27
+ import { withoutReplyFiles } from "../reply-files.ts";
27
28
  import { planOrder, type TransientTask } from "../sessions.ts";
28
29
  import { textOf } from "../shared/session-messages.ts";
29
30
  import { addressee, type Speaker, type Tier } from "../speakers.ts";
@@ -352,7 +353,7 @@ export class PiAgentRuntime implements AgentRuntime {
352
353
  this.#tiers.allows(tier, name),
353
354
  );
354
355
  session.setActiveToolsByName([...worker.tools]);
355
- await session.prompt(task.text);
356
+ await withoutReplyFiles(() => session.prompt(task.text));
356
357
  return workerReport(
357
358
  session.messages,
358
359
  "the worker stopped before it reported",
@@ -1,5 +1,6 @@
1
1
  import type { TurnResult } from "../domain/conversation.ts";
2
2
  import { AgentRunError } from "../domain/errors.ts";
3
+ import { hasReplyFiles } from "../reply-files.ts";
3
4
  import { lastAssistant, textOf } from "../shared/session-messages.ts";
4
5
 
5
6
  interface MessagePart {
@@ -67,7 +68,7 @@ export function turnAnswer(
67
68
  .join("\n\n")
68
69
  : textOf(last.message.content)
69
70
  ).trim();
70
- if (!text)
71
+ if (!text && !hasReplyFiles())
71
72
  return {
72
73
  ok: false,
73
74
  error: new AgentRunError("the final assistant message has no text"),
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export type {
77
77
  HeldCall,
78
78
  OutboundReply,
79
79
  PendingConfirmation,
80
+ ReplyFile,
80
81
  TranscriptEntry,
81
82
  TurnResult,
82
83
  } from "./core/domain/conversation.ts";
@@ -157,6 +158,11 @@ export type {
157
158
  TurnEndEvent,
158
159
  TurnEvent,
159
160
  } from "./core/plugin.ts";
161
+ export {
162
+ attachReplyFile,
163
+ REPLY_FILE_LIMITS,
164
+ ReplyFileError,
165
+ } from "./core/reply-files.ts";
160
166
  export type {
161
167
  ConversationTurnInput,
162
168
  ConversationTurns,
package/src/testing.ts CHANGED
@@ -25,7 +25,10 @@ import type {
25
25
  InteractionContribution,
26
26
  } from "./core/discord/interaction-module.ts";
27
27
  import { OwnerGuard, ownerRootCommand } from "./core/discord/owner-command.ts";
28
- import type { PendingConfirmation } from "./core/domain/conversation.ts";
28
+ import type {
29
+ PendingConfirmation,
30
+ ReplyFile,
31
+ } from "./core/domain/conversation.ts";
29
32
  import { NotLinkedError, PluginError } from "./core/errors.ts";
30
33
  import { EventBus } from "./core/events.ts";
31
34
  import type { HoldCheck } from "./core/holds.ts";
@@ -48,6 +51,11 @@ import {
48
51
  } from "./core/registry/contributions.ts";
49
52
  import { resolveProviders } from "./core/registry/providers.ts";
50
53
  import { ServiceRegistry } from "./core/registry/services.ts";
54
+ import {
55
+ attachReplyFile,
56
+ hasReplyFileScope,
57
+ withReplyFiles,
58
+ } from "./core/reply-files.ts";
51
59
  import { ChannelQueue } from "./core/routing/channel-queue.ts";
52
60
  import { ChannelRouter } from "./core/routing/channel-router.ts";
53
61
  import {
@@ -203,6 +211,8 @@ export interface TestPluginResult {
203
211
  tools: readonly string[];
204
212
  tiers: ToolTierTable;
205
213
  events: RecordedEvent[];
214
+ /** Files accepted by successful runTool calls, copied and recorded with their channel. */
215
+ files: { channel: ChannelKey; file: ReplyFile }[];
206
216
  runTool(
207
217
  name: string,
208
218
  args: Record<string, unknown>,
@@ -233,7 +243,10 @@ function unlinked(part: keyof typeof NOT_LINKED): never {
233
243
  const PROBED = new Set(["then", "toJSON", "asymmetricMatch"]);
234
244
 
235
245
  /** A service the test gave only some members of: reading another names the option that adds it. */
236
- function partialService(key: ServiceKey<unknown>, given: object): object {
246
+ function partialService<T extends object>(
247
+ key: ServiceKey<unknown>,
248
+ given: T,
249
+ ): T {
237
250
  return new Proxy(given, {
238
251
  get(target, member, receiver) {
239
252
  if (member in target || typeof member === "symbol" || PROBED.has(member))
@@ -495,6 +508,7 @@ export async function testPlugin(
495
508
  for (const surface of options.surfaces ?? []) await surface.start(deliver);
496
509
  await runtime?.preflight?.();
497
510
  let stopped = false;
511
+ const files: { channel: ChannelKey; file: ReplyFile }[] = [];
498
512
  return {
499
513
  contribution,
500
514
  holds: linked.holds,
@@ -505,6 +519,7 @@ export async function testPlugin(
505
519
  tools: registry.tools.map((tool) => tool.name),
506
520
  tiers,
507
521
  events,
522
+ files,
508
523
  async runTool(name, args, runOptions) {
509
524
  const tool = registry.tools.find((item) => item.name === name);
510
525
  if (!tool) throw new PluginError(`tool ${name} is not registered`);
@@ -539,8 +554,23 @@ export async function testPlugin(
539
554
  const execute = registered[0]?.execute;
540
555
  if (!execute)
541
556
  throw new PluginError(`tool ${name} did not register in the session`);
542
- const result = await execute("test-call", args);
543
- return result.content.map((item) => item.text ?? "").join("\n");
557
+ const result = await withReplyFiles(
558
+ surfaces.of(channel)?.supportsFiles === true,
559
+ async () => {
560
+ const output = await execute("test-call", args);
561
+ return {
562
+ ok: true,
563
+ text: output.content.map((item) => item.text ?? "").join("\n"),
564
+ };
565
+ },
566
+ );
567
+ if (!result.ok) throw result.error;
568
+ for (const file of result.files ?? []) {
569
+ files.push({ channel, file });
570
+ // When runTool is used by a fake runtime, forward into the enclosing real turn.
571
+ if (hasReplyFileScope()) attachReplyFile(file);
572
+ }
573
+ return result.text;
544
574
  },
545
575
  async stop() {
546
576
  if (stopped) return;
@@ -0,0 +1,248 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ AGENT_SERVER_PLUGIN,
7
+ AGENT_TEAM_SERVICE,
8
+ AGENTS,
9
+ type AgentTeam,
10
+ } from "pi-roundtable";
11
+ import { partial, servicePair, testPlugin } from "pi-roundtable/testing";
12
+ import { createReleaseNotice, noticeText } from "./release-notice.ts";
13
+
14
+ let dir: string;
15
+ let posted: string[];
16
+
17
+ beforeEach(async () => {
18
+ dir = await mkdtemp(join(tmpdir(), "release-notice-"));
19
+ posted = [];
20
+ });
21
+ afterEach(() => rm(dir, { recursive: true, force: true }));
22
+
23
+ const releaseFile = () => join(dir, "release.json");
24
+ const dataDir = () => join(dir, "data");
25
+ const writeRelease = (sha: string, commits: string[]) =>
26
+ writeFile(releaseFile(), JSON.stringify({ sha, commits }));
27
+
28
+ /** The plugin over the temporary directory, posting into `posted`; `fail` makes the post throw. */
29
+ async function started(fail = false) {
30
+ const harness = await testPlugin(
31
+ createReleaseNotice({
32
+ releaseFile: releaseFile(),
33
+ dataDir: dataDir(),
34
+ announce: async (text) => {
35
+ if (fail) throw new Error("the channel is gone");
36
+ posted.push(text);
37
+ },
38
+ }),
39
+ );
40
+ const events = harness.contribution.events;
41
+ return {
42
+ harness,
43
+ /** What the host delivers when the agent server's channels are up. */
44
+ ready: () =>
45
+ events?.serviceStarted?.({
46
+ plugin: AGENT_SERVER_PLUGIN,
47
+ service: AGENT_TEAM_SERVICE,
48
+ outcome: "ready",
49
+ }),
50
+ /** What the host delivers when the shutdown drain ends. */
51
+ shutdown: (left: string[]) => events?.shutdown?.(left),
52
+ };
53
+ }
54
+
55
+ test("a start with no release.json announces nothing", async () => {
56
+ const run = await started();
57
+ await run.ready();
58
+ expect(posted).toEqual([]);
59
+ await run.harness.stop();
60
+ });
61
+
62
+ test("a new release is announced once, with its commits", async () => {
63
+ await writeRelease("abc1234", ["fix: b", "feat: a"]);
64
+ const first = await started();
65
+ await first.ready();
66
+ expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b\n- feat: a"]);
67
+ await first.harness.stop();
68
+ // The next start of the same release says nothing.
69
+ const second = await started();
70
+ await second.ready();
71
+ expect(posted).toHaveLength(1);
72
+ await second.harness.stop();
73
+ // A different release is announced again.
74
+ await writeRelease("def5678", ["feat: c"]);
75
+ const third = await started();
76
+ await third.ready();
77
+ expect(posted[1]).toBe("🔄 Updated to `def5678`\n- feat: c");
78
+ await third.harness.stop();
79
+ });
80
+
81
+ test("only the agent server's ready event announces", async () => {
82
+ await writeRelease("abc1234", ["fix: b"]);
83
+ const harness = await testPlugin(
84
+ createReleaseNotice({
85
+ releaseFile: releaseFile(),
86
+ dataDir: dataDir(),
87
+ announce: async (text) => {
88
+ posted.push(text);
89
+ },
90
+ }),
91
+ );
92
+ const serviceStarted = harness.contribution.events?.serviceStarted;
93
+ await serviceStarted?.({
94
+ plugin: AGENT_SERVER_PLUGIN,
95
+ service: AGENT_TEAM_SERVICE,
96
+ outcome: "failed",
97
+ });
98
+ await serviceStarted?.({
99
+ plugin: "other",
100
+ service: AGENT_TEAM_SERVICE,
101
+ outcome: "ready",
102
+ });
103
+ await serviceStarted?.({
104
+ plugin: AGENT_SERVER_PLUGIN,
105
+ service: "other",
106
+ outcome: "ready",
107
+ });
108
+ expect(posted).toEqual([]);
109
+ await harness.stop();
110
+ });
111
+
112
+ test("work a shutdown cut short is reported once, after a restart of the same release", async () => {
113
+ await writeRelease("abc1234", ["fix: b"]);
114
+ const first = await started();
115
+ await first.ready();
116
+ await first.shutdown(["discord:111", "agentgroup:222.infra", "discord:111"]);
117
+ const second = await started();
118
+ await second.ready();
119
+ expect(posted[1]).toBe(
120
+ "🔄 Restarted (`abc1234`)\n-# Cut short by the restart, still running when the shutdown wait ended: <#111>, <#222>",
121
+ );
122
+ await second.harness.stop();
123
+ const third = await started();
124
+ await third.ready();
125
+ expect(posted).toHaveLength(2);
126
+ await third.harness.stop();
127
+ });
128
+
129
+ test("a shutdown that left nothing records nothing", async () => {
130
+ await writeRelease("abc1234", []);
131
+ const first = await started();
132
+ await first.ready();
133
+ await first.shutdown([]);
134
+ await first.harness.stop();
135
+ const second = await started();
136
+ await second.ready();
137
+ expect(posted).toHaveLength(1);
138
+ await second.harness.stop();
139
+ });
140
+
141
+ test("a post that fails leaves the release unannounced, so the next start tries again", async () => {
142
+ await writeRelease("abc1234", ["fix: b"]);
143
+ const failing = await started(true);
144
+ await expect(failing.ready()).rejects.toThrow("the channel is gone");
145
+ await failing.harness.stop();
146
+ expect(await Bun.file(join(dataDir(), "announced-release")).exists()).toBe(
147
+ false,
148
+ );
149
+ const next = await started();
150
+ await next.ready();
151
+ expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
152
+ await next.harness.stop();
153
+ });
154
+
155
+ test("a release.json that is not a valid description fails with its path and announces nothing", async () => {
156
+ for (const bad of [
157
+ "",
158
+ "{ not json",
159
+ "null",
160
+ "[]",
161
+ "{}",
162
+ '{"sha":"","commits":[]}',
163
+ '{"sha":"abc1234"}',
164
+ '{"sha":"abc1234","commits":"fix: b"}',
165
+ '{"sha":"abc1234","commits":["fix: b",null,{}]}',
166
+ ]) {
167
+ await writeFile(releaseFile(), bad);
168
+ const run = await started();
169
+ await expect(run.ready()).rejects.toThrow(
170
+ `${releaseFile()} is not a valid release description`,
171
+ );
172
+ await run.harness.stop();
173
+ }
174
+ expect(posted).toEqual([]);
175
+ });
176
+
177
+ test("a recorded list that is not a list of strings fails with its path", async () => {
178
+ await writeRelease("abc1234", []);
179
+ await mkdir(dataDir(), { recursive: true });
180
+ await writeFile(join(dataDir(), "aborted-on-shutdown.json"), '["a", 1]');
181
+ const run = await started();
182
+ await expect(run.ready()).rejects.toThrow("is not a list of strings");
183
+ await run.harness.stop();
184
+ });
185
+
186
+ test("a shutdown during the post is not lost, and one that ends before the next start adds to the unannounced", async () => {
187
+ await writeRelease("abc1234", ["fix: b"]);
188
+ let finish = () => {};
189
+ const harness = await testPlugin(
190
+ createReleaseNotice({
191
+ releaseFile: releaseFile(),
192
+ dataDir: dataDir(),
193
+ announce: (text) =>
194
+ new Promise<void>((resolve) => {
195
+ posted.push(text);
196
+ finish = resolve;
197
+ }),
198
+ }),
199
+ );
200
+ const events = harness.contribution.events;
201
+ const announcing = events?.serviceStarted?.({
202
+ plugin: AGENT_SERVER_PLUGIN,
203
+ service: AGENT_TEAM_SERVICE,
204
+ outcome: "ready",
205
+ });
206
+ // The host shuts down while the notice is still being posted.
207
+ while (posted.length === 0) await Bun.sleep(1);
208
+ await events?.shutdown?.(["discord:111"]);
209
+ finish();
210
+ await announcing;
211
+ await events?.shutdown?.(["discord:333"]);
212
+ await harness.stop();
213
+ const next = await started();
214
+ await next.ready();
215
+ expect(posted[1]).toContain("<#111>, <#333>");
216
+ await next.harness.stop();
217
+ });
218
+
219
+ test("without an announce option the notice goes to the agent team", async () => {
220
+ await writeRelease("abc1234", ["fix: b"]);
221
+ const harness = await testPlugin(
222
+ createReleaseNotice({ releaseFile: releaseFile(), dataDir: dataDir() }),
223
+ {
224
+ services: [
225
+ servicePair(AGENTS, {
226
+ team: partial<AgentTeam>({
227
+ announce: async (text) => {
228
+ posted.push(text);
229
+ },
230
+ }),
231
+ }),
232
+ ],
233
+ },
234
+ );
235
+ await harness.contribution.events?.serviceStarted?.({
236
+ plugin: AGENT_SERVER_PLUGIN,
237
+ service: AGENT_TEAM_SERVICE,
238
+ outcome: "ready",
239
+ });
240
+ expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
241
+ await harness.stop();
242
+ });
243
+
244
+ test("a channel key that is not Discord's is named as it is", () => {
245
+ expect(noticeText({ sha: "a", commits: [] }, false, ["slack:C1"])).toContain(
246
+ "slack:C1",
247
+ );
248
+ });
@@ -0,0 +1,194 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ AGENT_SERVER_PLUGIN,
5
+ AGENT_TEAM_SERVICE,
6
+ AGENTS,
7
+ type AgentServer,
8
+ definePlugin,
9
+ } from "pi-roundtable";
10
+
11
+ /**
12
+ * What a deploy writes as `release.json`: the commit the running release was built from, and
13
+ * the subjects of the commits it added since the release that ran before, newest first.
14
+ */
15
+ export interface ReleaseInfo {
16
+ sha: string;
17
+ commits: string[];
18
+ }
19
+
20
+ export interface ReleaseNoticeOptions {
21
+ /** The file a deploy writes with the release's description; default `release.json` in the working directory. A start with no such file announces nothing. */
22
+ releaseFile?: string;
23
+ /** Where the plugin keeps what it has announced and what a shutdown cut short; default `./data`, the data directory a new project's config names. */
24
+ dataDir?: string;
25
+ /** Posts the announcement; default the coordinator's channel, through the agent server. It throws to say the post failed, and the announcement is then tried again at the next start. */
26
+ announce?: (text: string) => Promise<void>;
27
+ }
28
+
29
+ const ANNOUNCED = "announced-release";
30
+ const ABORTED = "aborted-on-shutdown.json";
31
+
32
+ async function readText(path: string): Promise<string | undefined> {
33
+ return readFile(path, "utf8").catch((error: NodeJS.ErrnoException) => {
34
+ if (error.code === "ENOENT") return undefined;
35
+ throw error;
36
+ });
37
+ }
38
+
39
+ /** A list of strings from JSON, such as the aborted channels; anything else fails with the file's path. */
40
+ function parseStrings(text: string, path: string): string[] {
41
+ let parsed: unknown;
42
+ try {
43
+ parsed = JSON.parse(text);
44
+ } catch (error) {
45
+ throw new Error(`${path} is not a JSON list`, { cause: error });
46
+ }
47
+ if (!isStrings(parsed)) throw new Error(`${path} is not a list of strings`);
48
+ return parsed;
49
+ }
50
+
51
+ const isStrings = (value: unknown): value is string[] =>
52
+ Array.isArray(value) && value.every((item) => typeof item === "string");
53
+
54
+ /** `release.json`'s content; anything but a non-empty `sha` and a list of commit subjects fails with the file's path. */
55
+ function parseRelease(text: string, path: string): ReleaseInfo {
56
+ const invalid = (reason: string, cause?: unknown) =>
57
+ new Error(`${path} is not a valid release description: ${reason}`, {
58
+ cause,
59
+ });
60
+ let parsed: Partial<ReleaseInfo> | null;
61
+ try {
62
+ parsed = JSON.parse(text);
63
+ } catch (error) {
64
+ throw invalid("not JSON", error);
65
+ }
66
+ if (typeof parsed?.sha !== "string" || parsed.sha === "")
67
+ throw invalid("needs a non-empty sha");
68
+ if (!isStrings(parsed.commits))
69
+ throw invalid("commits is not a list of strings");
70
+ return { sha: parsed.sha, commits: parsed.commits };
71
+ }
72
+
73
+ /** `discord:<id>` and `agentgroup:<id>.<agent>` as a Discord channel mention; any other key as it is. */
74
+ function channelMention(key: string): string {
75
+ const id =
76
+ /^discord:(\d+)$/.exec(key)?.[1] ?? /^agentgroup:(\d+)\./.exec(key)?.[1];
77
+ return id ? `<#${id}>` : key;
78
+ }
79
+
80
+ /** The announcement: the new version and its commits, or a plain restart, then any channels the previous shutdown cut short. */
81
+ export function noticeText(
82
+ release: ReleaseInfo,
83
+ updated: boolean,
84
+ aborted: string[],
85
+ ): string {
86
+ const lines = updated
87
+ ? [
88
+ `🔄 Updated to \`${release.sha}\``,
89
+ ...release.commits.map((subject) => `- ${subject}`),
90
+ ]
91
+ : [`🔄 Restarted (\`${release.sha}\`)`];
92
+ if (aborted.length > 0)
93
+ lines.push(
94
+ `-# Cut short by the restart, still running when the shutdown wait ended: ${[...new Set(aborted.map(channelMention))].join(", ")}`,
95
+ );
96
+ return lines.join("\n");
97
+ }
98
+
99
+ /** Posts through the agent server's team, read when the first notice is due, since the server is linked after setup. */
100
+ function throughTeam(agents: () => AgentServer) {
101
+ return (text: string) => agents().team.announce(text);
102
+ }
103
+
104
+ /**
105
+ * The plugin, with where the release is described, where its state is kept, and where the
106
+ * announcement goes replaceable. After the agent server is up it announces once when the
107
+ * running release differs from the one last announced, or when the previous shutdown cut work
108
+ * short; at shutdown it records the channels whose work the drain gave up on.
109
+ */
110
+ export function createReleaseNotice(options: ReleaseNoticeOptions = {}) {
111
+ const releaseFile = options.releaseFile ?? "release.json";
112
+ const dataDir = options.dataDir ?? "./data";
113
+ const announcedPath = join(dataDir, ANNOUNCED);
114
+ const abortedPath = join(dataDir, ABORTED);
115
+
116
+ // The shutdown's record and an announcement's acknowledgement both rewrite the state files, and
117
+ // the host does not wait for a start's handlers before it shuts down, so they take turns.
118
+ let turn: Promise<unknown> = Promise.resolve();
119
+ const exclusive = <T>(work: () => Promise<T>): Promise<T> => {
120
+ const run = turn.then(work);
121
+ turn = run.catch(() => undefined);
122
+ return run;
123
+ };
124
+
125
+ async function readAborted(): Promise<string[]> {
126
+ const text = await readText(abortedPath);
127
+ return text === undefined ? [] : parseStrings(text, abortedPath);
128
+ }
129
+
130
+ /** The announcement due now and what acknowledges it, or undefined when nothing is due. */
131
+ function pending() {
132
+ return exclusive(async () => {
133
+ const text = await readText(releaseFile);
134
+ if (text === undefined) return undefined;
135
+ const release = parseRelease(text, releaseFile);
136
+ const announced = (await readText(announcedPath))?.trim();
137
+ const aborted = await readAborted();
138
+ const updated = announced !== release.sha;
139
+ if (!updated && aborted.length === 0) return undefined;
140
+ return {
141
+ text: noticeText(release, updated, aborted),
142
+ // A shutdown may add channels while the post is in flight: only the ones it named are cleared.
143
+ done: () =>
144
+ exclusive(async () => {
145
+ await mkdir(dataDir, { recursive: true });
146
+ await writeFile(announcedPath, release.sha);
147
+ const rest = (await readAborted()).slice(aborted.length);
148
+ if (rest.length > 0)
149
+ await writeFile(abortedPath, JSON.stringify(rest));
150
+ else await rm(abortedPath, { force: true });
151
+ }),
152
+ };
153
+ });
154
+ }
155
+
156
+ return definePlugin({
157
+ name: "release-notice",
158
+ setup: ({ logger, services }) => {
159
+ const announce = options.announce ?? throughTeam(services.lazy(AGENTS));
160
+ return {
161
+ events: {
162
+ // The agent server's team service is ready once its channels are up, so the notice posts after it.
163
+ serviceStarted: async ({ plugin, service, outcome }) => {
164
+ if (
165
+ plugin !== AGENT_SERVER_PLUGIN ||
166
+ service !== AGENT_TEAM_SERVICE ||
167
+ outcome !== "ready"
168
+ )
169
+ return;
170
+ const notice = await pending();
171
+ if (!notice) return;
172
+ await announce(notice.text);
173
+ await notice.done();
174
+ logger.info("release announced");
175
+ },
176
+ // The drain ended with work still running: the next start says it was cut short.
177
+ shutdown: (left) =>
178
+ left.length === 0
179
+ ? undefined
180
+ : exclusive(async () => {
181
+ const earlier = await readAborted();
182
+ await mkdir(dataDir, { recursive: true });
183
+ await writeFile(
184
+ abortedPath,
185
+ JSON.stringify([...earlier, ...left]),
186
+ );
187
+ }),
188
+ },
189
+ };
190
+ },
191
+ });
192
+ }
193
+
194
+ export const releaseNotice = createReleaseNotice();