pi-roundtable 0.20.0 → 0.22.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,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.22.0] - 2026-10-21
9
+
10
+ ### Added
11
+
12
+ - Worker sessions for plugins (`pi-roundtable`). A new built-in plugin, `worker-sessions` (after `modules`), provides `WORKER_SESSIONS`, a `WorkerSessions`: `run(request)` runs a `WorkerSessionRequest` (`task`, `signal`, `prompt`, `extensions`, `toolNames`, and optionally `model`, `thinking` and `aborted`) in a fresh, unsaved Pi session on the host's model login and resolves to the worker's final answer, rejecting with an `AgentRunError` when it ends without one and with the signal's reason when it is aborted; the session is disposed either way. The session has no built-in tool, skill, context file, memory or agent tool, and only the tools its `extensions` register and `toolNames` names are active, even if an extension activates another. `model` and `thinking` default to the host's `delegation` settings, shown as `WorkerSessions.delegation`. The delegation worker for web research now runs on the same code. It is a separate plugin, not part of `modules`, so a host that replaces `BACKGROUND_TURNS` and `DELEGATION` still replaces that plugin whole. The built-in plugin names gain `worker-sessions`, so a plugin of that name is now refused. See [Workers of your own](docs/plugins.md#workers-of-your-own).
13
+ - `pi-roundtable-browser`, a new official workspace package published in lockstep (not yet on npm; see `.github/PACKAGE-RELEASES.md`): `browserAgent(options)` adds the owner-tier tool `browser_task({ task, url? })`, which runs a fresh worker session that drives a real browser through a `BrowserDriver` and returns its report, attaching the screenshots it kept to the agent's reply. The default driver runs agent-browser and Chromium in a Docker container started per task and removed after it, with no network but a unix socket to a host-side proxy that refuses every destination that is not a public address. See the package README and `site/src/content/docs/plugins/browser-package.mdx`.
14
+ - Skills from an npm package (`pi-roundtable`, `pi-roundtable-web`). `skills: { packages: ["pi-workflow-skills"] }` (the new type `SkillsConfig`) names packages the host installed; at startup the registry reads each package's `pi.skills` folders and offers every skill found as a fourth kind, `package` (`SkillSource` gains `{ kind: "package"; package }`; `SkillCatalogEntry.source` and the web console's skill list show the package). A package skill is read-only, named by its frontmatter and resolved to the installed file on every read, so upgrading the package changes the skill with no database write. Agents carry package skills and groups include them exactly like linked or written ones (`agent_skills`, `agent_create`, `skill_group_set`, `repo_task`); `skill_unlink`, `skill_update` and `skill_delete` refuse a package skill with a message saying it comes from the package, and `skill_link` and `skill_create` refuse a name a package skill holds. A package that is not installed, a package without `pi.skills`, two packages offering one name, or a package skill named like a built-in, linked or written skill stops startup with a `PluginError` naming the fix. A carried package skill that an upgrade removed is reported missing and skipped, as a vanished linked skill is.
15
+
16
+ ## [0.21.0] - 2026-10-20
17
+
18
+ ### Added
19
+
20
+ - A held call's approval card can be its speaker's alone (`pi-roundtable`). A hold answers `{ text, approval: "speaker" }` (`HoldDescription.approval`, beside `copy` and `supersede`; a tool's `hold` and a rule's `describeInTurn` both may) and the card reaches the surface as `ApprovalOptions.speakerOnly`. Only the speaker whose turn made the call answers it, on any of their identities the surface knows and at the call's tier, checked again when they press: in a shared conversation no owner does, and an owner's press or decline is refused with the card's own line while the card stays open for the speaker. A speaker below the tier, a turn with no speaker and a speaker with no Discord identity get `unavailable` at once, shown to no one, so the call stays held. With `approvers` the card is still the speaker's alone, and the runtime accepts their press only when the approvers name them or they are an owner. The option governs the card only; a message that confirms the held call is judged as before, so a tool that must act on the speaker's press alone checks `ToolTurn.approvedPrompt` and `ToolTurn.approvedBy` in `run`. Without it every card behaves as before. The Discord cards and the web chat honour it, and `SurfaceContractSubject.speakerOnly` makes `describeSurfaceContract` check, with an `owner`, that an owner's answer leaves such an approval open in a shared conversation; a subject that does not declare it skips the check.
21
+
8
22
  ## [0.20.0] - 2026-10-18
9
23
 
10
24
  ### Added
package/docs/plugins.md CHANGED
@@ -50,6 +50,29 @@ Their exported names are grouped by area in the source, with one entry for each;
50
50
  The [changelog](../CHANGELOG.md) lists every exported name, including type-only contracts.
51
51
  Import fixtures and fake threads from `pi-roundtable/testing` for tests.
52
52
 
53
+ #### Workers of your own
54
+
55
+ A plugin that gives a model its own small set of tools, such as a worker that drives a browser, asks the host for a worker session instead of building one from Pi's parts:
56
+
57
+ ```ts
58
+ const sessions = context.services.get(WORKER_SESSIONS);
59
+ const report = await sessions.run({
60
+ task: "What the worker is asked to do.",
61
+ signal: turn.signal,
62
+ prompt: "You are a ... worker. Report in the task's language.",
63
+ extensions: [registersTheWorkersTools],
64
+ toolNames: ["my_tool"],
65
+ });
66
+ ```
67
+
68
+ `run` starts a fresh session that is never saved, on the host's model login (the same credentials and refreshes the agents use), with the `model` and `thinking` of the host's `delegation` settings unless the request names its own (`model` is `<provider>/<id>`; `sessions.delegation` shows the defaults).
69
+ The session has no built-in tool, no skills, no context files, no memory and none of the agents' tools: only the tools the `extensions` register with `pi.registerTool` and `toolNames` lists are active, and an extension that activates another tool is undone.
70
+ `prompt` is added to its system prompt, so the standing rules and any instructions the worker should follow, such as a tool's usage guide, go there.
71
+ `run` resolves to the worker's final answer, rejects with an `AgentRunError` when it ends without one, and rejects when `signal` aborts (`aborted` is the message of that rejection, default "the worker ran out of time"); the session is disposed either way.
72
+ The tools are the plugin's to bound: the worker has no tier, no hold and no conversation, so a tool checks what the worker asks for, limits its work, and cleans up in a `finally` around `run`.
73
+ A file a tool produces reaches the person only through the tool that called `run`: collect it and call `turn.attachFile` after `run` returns, as the [browser package source][browser-source] does with screenshots.
74
+ The `worker-sessions` plugin provides the service on every host.
75
+
53
76
  #### Helpers for a Pi session of your own
54
77
 
55
78
  Use the kit's building blocks for a plugin that runs Pi itself, such as a coding worker:
@@ -218,6 +241,7 @@ The built-in plugins provide these, from the main entry:
218
241
  | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
219
242
  | `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
220
243
  | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
244
+ | `WORKER_SESSIONS` | `WorkerSessions` | `worker-sessions` | Fresh Pi sessions for a plugin's own workers, on the host's model login: `delegation` is the host's delegation `model` and `thinking`, and `run({ task, signal, prompt, extensions, toolNames, model?, thinking?, aborted? })` runs the task in a session that has only the tools `extensions` register and `toolNames` names (no built-in tool, skill, context file, memory or agent tool) and returns the worker's final answer; see [Workers of your own](#workers-of-your-own) |
221
245
 
222
246
  The data types the ports use (`Schedule`, `NewSchedule`, `Agent`, `AgentGroup`, `TeamStatus`, `Memory`, `SkillSet`, `DelegationJob`, and so on) are in the main entry too.
223
247
  Your plugins run after the built-ins, so they can read every key above.
@@ -366,7 +390,16 @@ You can switch them off in the configuration.
366
390
  | Discord administration | `discord-admin` | `discord: { admin: false }` | The `discord_*` tools that read and manage the server, for the owner; they check what the primary owner may do on the server, whichever owner's turn calls them | No `discord_*` tools; the channel executor that remote MCP uses is the connection's, so it stays |
367
391
 
368
392
  Switching an addon off leaves its tables and rows unchanged, ready for when you turn it on again.
369
- `skills` also takes the two directories (`skills: { builtinDir, reposDir }`) when it is on.
393
+ `skills` also takes the two directories and the skill packages (`skills: { builtinDir, reposDir, packages }`) when it is on.
394
+
395
+ `skills.packages` lists npm packages the host installed, for example `["pi-workflow-skills"]`.
396
+ At startup the registry reads each package's `pi.skills` folders (Pi's package manifest) and offers every skill it finds there as a fourth kind, `package`, beside the built-in, linked and written ones.
397
+ A package skill is named by its frontmatter and resolved to the installed file on every read, so upgrading the package changes the skill and nothing is written to the database.
398
+ Agents carry package skills and groups include them exactly like the others: `agent_skills`, `agent_create`'s `skills` field, `skill_group_set` and `repo_task` accept their names.
399
+ `skill_unlink`, `skill_update` and `skill_delete` refuse a package skill, naming the package, and `skill_link` and `skill_create` refuse a name a package skill already holds.
400
+ `SkillCatalogEntry.source` is `{ kind: "package", package }` for them, and the web console's skill list shows the package.
401
+ A package that is not installed, a package with no `pi.skills` (or none that holds a `SKILL.md`), two packages offering one name, a package skill with the name of a built-in, linked or written skill: each stops startup with a `PluginError` naming the fix.
402
+ A carried package skill that an upgrade removed is reported missing and skipped, as a linked skill whose file vanished is.
370
403
 
371
404
  Use `find` for an optional addon service; it returns `undefined` while the addon is off.
372
405
  Use `get` if your plugin needs the addon; it fails with a message naming the switch:
@@ -876,6 +909,21 @@ A rule whose verdict depends on the input, such as one that holds only a `delete
876
909
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
877
910
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
878
911
 
912
+ #### A hold that makes its card the speaker's alone
913
+
914
+ By default the speaker whose turn made a held call may answer its card, and in a shared conversation so may every owner. A hold that wants only the speaker to press, such as one whose tool books something under the speaker's name, answers `{ text, approval: "speaker" }` (`HoldDescription.approval`, beside `copy` and `supersede`; a tool's `hold` and a rule's `describeInTurn` both may):
915
+
916
+ ```ts
917
+ hold: async ({ draft }, turn) => ({ text: await summaryOf(draft, turn), approval: "speaker" }),
918
+ ```
919
+
920
+ - Only the speaker answers the card: their principal, on any of their identities the surface knows, at the call's tier, checked again when they press. An owner's press is refused with the card's own line, the card stays open for the speaker, and the owner's decline is refused too. An owner who is the speaker answers as the speaker.
921
+ - It holds for a late press as well: a card answered after the turn stopped waiting is still the speaker's alone, so the turn that follows is started by the speaker.
922
+ - A speaker below the call's tier, a turn with no speaker, and a surface that cannot tell who the speaker is get `unavailable` at once, shown to no one, and the call stays held, as for any card nobody may answer. It never falls back to the owners.
923
+ - Combined with [approvers](#a-hold-that-names-its-approvers) the card is still the speaker's alone, and the runtime accepts the press only when the approvers name the speaker (or they are an owner); otherwise the call stays held.
924
+ - The option governs the card only. A message that confirms the held call is judged as for any call, so an owner's message can still release it. A tool that must act on the speaker's press alone checks in `run` that `turn.approvedPrompt` is set (a message sets none) and that `turn.approvedBy?.speakerId` is the speaker's.
925
+ - Anything but `"speaker"` leaves the card as every card is. A surface implements this through `ApprovalOptions.speakerOnly`; the Discord cards and the web chat (whose prompts are its person's alone) honour it, and the contract checks it where a subject declares `speakerOnly`.
926
+
879
927
  #### A hold that names its approvers
880
928
 
881
929
  A held call is approved by the speaker whose turn made it, or by an owner where the conversation is shared. A tool whose call stands for someone else, such as a message sent under another person's name, can name who approves it instead:
@@ -2645,6 +2693,8 @@ A surface's prompts keep these rules:
2645
2693
  - Without a scope, as for a turn with no speaker, the prompts are the owners'.
2646
2694
  - An approval that names [approvers](#a-hold-that-names-its-approvers) (`ApprovalOptions.approvers`, which `confirm` receives beside the scope) is theirs **instead of the speaker's**, where the scope escalates to the owners: each named principal answers on any of their identities the surface knows, at the tier it needs, checked again when they answer; the speaker does not answer it unless named, and an owner still does. When none of the named has an identity on the surface, or none is named, it is the owners' alone. Where the scope escalates to no one, the person alone answers, and only when they are named or an owner: for anyone else the approval resolves `unavailable` at once, shown to no one. The runtime also refuses such a call before it asks, whatever the surface does.
2647
2695
 
2696
+ - An approval that is the [speaker's alone](#a-hold-that-makes-its-card-the-speakers-alone) (`ApprovalOptions.speakerOnly`) is theirs without the owner escalation, whatever the scope says: the speaker's principal answers on any of their identities the surface knows, at the tier it needs, checked again when they answer, and no owner does. Without a scope, or when the speaker is below the tier or has no identity on the surface, it resolves `unavailable` at once, shown to no one.
2697
+
2648
2698
  On Discord a card for named approvers mentions every Discord identity of every named principal that has one, in a thread, and refuses anyone else with a line that says it is for the people it names and the owners.
2649
2699
  The web chat shows prompts to the conversation's person only. The runtime never asks it about a call that names approvers the person is neither among nor an owner of, and the person is the approver of one that names them.
2650
2700
 
@@ -2656,7 +2706,7 @@ The web chat shows prompts to the conversation's person only: its owners are not
2656
2706
 
2657
2707
  `confirm(title, message, signal?, minTier?, wait?, details?, options?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
2658
2708
  The confirmation gate and a coding worker's cards pass both.
2659
- `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. `approvedPrompt(prompt)` is called, likewise before `confirm` resolves `approved`, with the id of the card that approved the call, for `ToolTurn.approvedPrompt`; a surface whose cards have no stable id leaves it uncalled. A surface may ignore all three for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2709
+ `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), `speakerOnly`, true when the card is the speaker's alone and no owner may answer it (see [a hold that makes its card the speaker's alone](#a-hold-that-makes-its-card-the-speakers-alone); a surface that cannot honour it resolves `unavailable` instead of showing the card to the owners), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. `approvedPrompt(prompt)` is called, likewise before `confirm` resolves `approved`, with the id of the card that approved the call, for `ToolTurn.approvedPrompt`; a surface whose cards have no stable id leaves it uncalled. A surface may ignore all three for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2660
2710
  A surface that shows text, such as Discord, uses `message` and reads only `details.copy` (below); the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2661
2711
  A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2662
2712
 
@@ -3145,7 +3195,7 @@ The [pi-roundtable-mcp][mcp-package] workspace, published separately on npm, add
3145
3195
  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.
3146
3196
  Install it with `bun add pi-roundtable-mcp`.
3147
3197
 
3148
- Six official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
3198
+ Seven official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
3149
3199
  The [site guide source files][package-guides] describe installation, requirements, configuration, and security considerations:
3150
3200
 
3151
3201
  - [pi-roundtable-drawing][drawing-package]: [site guide source][drawing-guide] · local relationship maps, magic circles, sigils, sacred geometry, and card spreads.
@@ -3153,6 +3203,7 @@ The [site guide source files][package-guides] describe installation, requirement
3153
3203
  - [pi-roundtable-web][web-package]: [site guide source][web-guide] · an owner-only console for conversations, transcripts, and memory notes with live updates.
3154
3204
  - [pi-roundtable-sandbox][sandbox-package]: [site guide source][sandbox-guide] · sealed guest channels on native Linux Docker with a host-only credential broker and allow-listed tools.
3155
3205
  - [pi-roundtable-mcp][mcp-package]: [site guide source][mcp-guide] · MCP connectors through a gateway and remote MCP endpoints for agent turns and owner-granted Discord channel tools.
3206
+ - [pi-roundtable-browser][browser-package]: [site guide source][browser-guide] · `browser_task`, a worker that drives agent-browser in a Docker container with no route to the host's private networks, and a `BrowserDriver` for another browser.
3156
3207
  - [pi-roundtable-webchat][webchat-package]: [site guide source][webchat-guide] · a WebSocket chat for people an OpenID Connect provider signs in, with private conversations, live progress, and approval cards; see [web chat](#web-chat-pi-roundtable-webchat).
3157
3208
 
3158
3209
  [drawing-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/drawing/README.md
@@ -3160,17 +3211,20 @@ The [site guide source files][package-guides] describe installation, requirement
3160
3211
  [web-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/web/README.md
3161
3212
  [sandbox-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/sandbox/README.md
3162
3213
  [mcp-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/mcp/README.md
3214
+ [browser-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/browser/README.md
3163
3215
  [package-guides]: https://github.com/wayne930242/pi-roundtable/tree/master/site/src/content/docs/plugins/
3164
3216
  [drawing-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/drawing-package.mdx
3165
3217
  [coding-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/coding-package.mdx
3166
3218
  [web-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/web-package.mdx
3167
3219
  [sandbox-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/sandbox-package.mdx
3168
3220
  [mcp-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/mcp-package.mdx
3221
+ [browser-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/browser-package.mdx
3169
3222
  [webchat-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/webchat-package.mdx
3170
3223
  [coding-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/coding
3171
3224
  [sandbox-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
3172
3225
  [web-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/web
3173
3226
  [drawing-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/drawing
3227
+ [browser-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/browser
3174
3228
  [mcp-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/mcp
3175
3229
  [codex-images-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/codex-images/plugin.ts
3176
3230
  [dice-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/dice/plugin.ts
@@ -3419,9 +3473,9 @@ test("the production configuration registers every plugin", async () => {
3419
3473
  ### `describeSurfaceContract`: what every chat surface keeps
3420
3474
 
3421
3475
  A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
3422
- `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
3476
+ `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `speakerOnly` marks a surface that honours `ApprovalOptions.speakerOnly` in a shared conversation, which the contract then checks with the `owner`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
3423
3477
  The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
3424
- It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3478
+ It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; with a `speakerOnly` subject and an `owner`, that an owner's answer leaves an approval that is the speaker's alone open in a shared conversation, unless the surface is `privateOnly`; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3425
3479
  A check for a part the surface does not offer passes.
3426
3480
  The contract has no check for `ApprovalDetails.copy`: a surface that shows it keeps it informational, so using it never resolves, approves or declines the prompt, whoever uses it.
3427
3481
  A subject that gives `answerQuestionnaire` (and a `stranger` that gives its own) must declare `asks` and also keeps the questionnaire checks.
@@ -3438,7 +3492,7 @@ The host's `run()` handles startup:
3438
3492
  The runtime plugin builds the runtime later, in its own setup: from the `runtime` slot when a plugin fills it, else the Pi runtime.
3439
3493
  2. The database is opened and every plugin's migrations run, in plugin order.
3440
3494
  3. Every plugin's `setup` runs, in plugin order.
3441
- The order is the built-ins (`memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`; an addon that is switched off is not there, and without Discord neither are `discord`, `discord-admin`, `skills`, `agent-server`, or `seeds`), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
3495
+ The order is the built-ins (`memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `worker-sessions`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`; an addon that is switched off is not there, and without Discord neither are `discord`, `discord-admin`, `skills`, `agent-server`, or `seeds`), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
3442
3496
  4. The contributions are linked: tool tiers, hold rules, the session plan, the channel router, and the events.
3443
3497
  From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
3444
3498
  5. Every plugin's `preflight` runs, in plugin order.
@@ -3526,7 +3580,7 @@ These are the messages as the code writes them, with `<...>` where your names go
3526
3580
  | `no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)` | Only a plugin that calls `providers.runtime` without `providers.filled.has("runtime")` sees it; read `services.get(RUNTIME)` instead |
3527
3581
  | `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
3528
3582
  | `plugin <name>: setup failed: the database was queried during setup…` (from `Roundtable.check()`, `roundtable doctor` or `start`) | A plugin's `setup` ran a query. Take `database()` in setup and query from a service's `start` or from a handler |
3529
- | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`, and `schedules` |
3583
+ | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `worker-sessions`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`, and `schedules` |
3530
3584
  | `migration <plugin>/<name> is declared twice` | A plugin declares two migrations with one name: rename one (the same name in two plugins is fine) |
3531
3585
  | `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
3532
3586
  | `route <name> is registered twice`, `routes <a> and <b> overlap on listener <id>` | Give each route its own name and a path no other route can take |
@@ -3868,8 +3922,12 @@ Import from the entries listed below; source area files are internal.
3868
3922
  | `SessionToolSnapshot` | `pi-roundtable` | type |
3869
3923
  | `SkillCatalogEntry` | `pi-roundtable` | type |
3870
3924
  | `SkillRegistry` | `pi-roundtable` | type |
3925
+ | `WorkerSessions` | `pi-roundtable` | type |
3926
+ | `WorkerSessionRequest` | `pi-roundtable` | type |
3927
+ | `WORKER_SESSIONS` | `pi-roundtable` | value |
3871
3928
  | `SkillSet` | `pi-roundtable` | type |
3872
3929
  | `SkillSource` | `pi-roundtable` | type |
3930
+ | `SkillsConfig` | `pi-roundtable` | type |
3873
3931
  | `Speaker` | `pi-roundtable` | type |
3874
3932
  | `SpeakerMemory` | `pi-roundtable` | type |
3875
3933
  | `StoredAttachment` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
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",
@@ -1,4 +1,5 @@
1
1
  import type { SQL } from "bun";
2
+ import { discoverPackageSkills } from "../modules/skills/package-skills.ts";
2
3
  import { StoredSkillRegistry } from "../modules/skills/skill-registry.ts";
3
4
  import { SkillStore } from "../modules/skills/skill-store.ts";
4
5
  import {
@@ -19,6 +20,10 @@ export interface SkillsOptions {
19
20
  reposDir: string;
20
21
  writtenDir: string;
21
22
  builtinDir: string;
23
+ /** npm packages whose `pi.skills` folders offer read-only skills; the host installed them. */
24
+ packages?: readonly string[];
25
+ /** Where a package is found; default the installed package's folder. */
26
+ packageDir?: (name: string) => string;
22
27
  }
23
28
 
24
29
  /** Admins manage skills; every speaker may look one up. */
@@ -47,11 +52,17 @@ export function skillsPlugin(
47
52
  // The skills are read once every plugin is set up, so a plugin may still place some in setup.
48
53
  preflight: () => registry?.init(),
49
54
  setup: async ({ database, services, logger }) => {
55
+ // Read before the store opens, so a missing package stops startup at once.
56
+ const packageSkills = discoverPackageSkills(
57
+ options.packages ?? [],
58
+ options.packageDir,
59
+ );
50
60
  const built = new StoredSkillRegistry({
51
61
  store: await openStore(database(), options.guildId),
52
62
  reposDir: options.reposDir,
53
63
  writtenDir: options.writtenDir,
54
64
  builtinDir: options.builtinDir,
65
+ packageSkills,
55
66
  logger,
56
67
  });
57
68
  registry = built;
@@ -0,0 +1,44 @@
1
+ import { join } from "node:path";
2
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ formatModelRef,
5
+ type ModelRef,
6
+ type ThinkingLevel,
7
+ } from "../models.ts";
8
+ import { workerSessions } from "../modules/delegation/worker-session.ts";
9
+ import type { RoundtablePlugin } from "../plugin.ts";
10
+ import { WORKER_SESSIONS } from "../services.ts";
11
+
12
+ export interface WorkerSessionsPluginOptions {
13
+ /** Shared with the rest of the host, so logins refresh in one place. */
14
+ modelRuntime: ModelRuntime;
15
+ agentDir: string;
16
+ dataDir: string;
17
+ /** The model and thinking level a worker session uses unless its request names its own. */
18
+ delegation: { model: ModelRef; thinking: ThinkingLevel };
19
+ }
20
+
21
+ /** Provides `WORKER_SESSIONS`: worker sessions on the host's model login, with its delegation settings as the defaults. */
22
+ export function workerSessionsPlugin(
23
+ options: WorkerSessionsPluginOptions,
24
+ ): RoundtablePlugin {
25
+ return {
26
+ name: "worker-sessions",
27
+ provides: [WORKER_SESSIONS],
28
+ setup: ({ services }) => {
29
+ services.provide(
30
+ WORKER_SESSIONS,
31
+ workerSessions({
32
+ modelRuntime: options.modelRuntime,
33
+ agentDir: options.agentDir,
34
+ workDir: join(options.dataDir, "workers"),
35
+ delegation: {
36
+ model: formatModelRef(options.delegation.model),
37
+ thinking: options.delegation.thinking,
38
+ },
39
+ }),
40
+ );
41
+ return {};
42
+ },
43
+ };
44
+ }
@@ -36,11 +36,11 @@ import {
36
36
  number,
37
37
  oneOf,
38
38
  optional,
39
- orOff,
40
39
  record,
41
40
  shape,
42
41
  text,
43
42
  } from "./schema.ts";
43
+ import { type SkillsConfig, skillsField } from "./skills-config.ts";
44
44
 
45
45
  export type { DiscordConfig };
46
46
 
@@ -168,7 +168,7 @@ export interface RoundtableConfig {
168
168
  * the `skills` addon out: agents carry no skills, and no skill tools exist. Stored skills stay
169
169
  * in their tables. Without `discord` there are no agents, so the addon is off.
170
170
  */
171
- skills?: false | { builtinDir?: string; reposDir?: string };
171
+ skills?: false | SkillsConfig;
172
172
  /**
173
173
  * Whether each speaker's memory is kept and offered as tools; default true. `false` leaves the
174
174
  * `memory` addon out: no memory tools, no memory prompt block, and the table stays as it is.
@@ -284,9 +284,7 @@ const schema = shape({
284
284
  avatar: optional(text),
285
285
  workDir: optional(text),
286
286
  scratchDir: optional(text),
287
- skills: optional(
288
- orOff(shape({ builtinDir: optional(text), reposDir: optional(text) })),
289
- ),
287
+ skills: optional(skillsField),
290
288
  memory: optional(bool),
291
289
  ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
292
290
  drainSeconds: optional(integer(1, 3_600)),
@@ -352,7 +350,7 @@ export interface ResolvedConfig {
352
350
  workDir: string;
353
351
  scratchDir: string;
354
352
  /** `false` when the skills addon is off. */
355
- skills: false | { builtinDir?: string; reposDir?: string };
353
+ skills: false | SkillsConfig;
356
354
  memory: boolean;
357
355
  ops?: { agent: string } | { conversation: ChannelKey };
358
356
  drainMs: number;
@@ -0,0 +1,23 @@
1
+ import { list, optional, orOff, shape, text } from "./schema.ts";
2
+
3
+ /** The `skills` setting when the addon is on. */
4
+ export interface SkillsConfig {
5
+ /** Where the registry reads the release's built-in skills. */
6
+ builtinDir?: string;
7
+ /** Where it keeps linked repositories; default `<dataDir>/repos`. */
8
+ reposDir?: string;
9
+ /**
10
+ * npm packages the host installed. Every skill in their `pi.skills` folders is offered
11
+ * read-only, and carried and grouped like any other. Startup fails, naming the fix, when a
12
+ * package is not installed, ships no skills, or holds a skill whose name another skill has.
13
+ */
14
+ packages?: string[];
15
+ }
16
+
17
+ export const skillsField = orOff(
18
+ shape({
19
+ builtinDir: optional(text),
20
+ reposDir: optional(text),
21
+ packages: optional(list(text)),
22
+ }),
23
+ );
@@ -13,6 +13,7 @@ import {
13
13
  precheckPlugin,
14
14
  scheduleStorePlugin,
15
15
  } from "./builtin/stores.ts";
16
+ import { workerSessionsPlugin } from "./builtin/worker-sessions.ts";
16
17
  import { warnDeprecations } from "./config/access.ts";
17
18
  import {
18
19
  type ResolvedConfig,
@@ -187,6 +188,7 @@ function discordAssembly(
187
188
  reposDir: config.skills.reposDir ?? join(config.dataDir, "repos"),
188
189
  writtenDir: join(config.dataDir, "skills"),
189
190
  builtinDir: config.skills.builtinDir ?? join(ASSETS, "skills"),
191
+ packages: config.skills.packages ?? [],
190
192
  }),
191
193
  ]
192
194
  : [],
@@ -321,6 +323,12 @@ export async function defineRoundtable(
321
323
  ? { errorReporter }
322
324
  : {}),
323
325
  }),
326
+ workerSessionsPlugin({
327
+ modelRuntime,
328
+ agentDir: config.agentDir,
329
+ dataDir: config.dataDir,
330
+ delegation: config.delegation,
331
+ }),
324
332
  ...(assembly?.admin ?? []),
325
333
  ...(assembly?.skills ?? []),
326
334
  conversationsPlugin(),
@@ -58,7 +58,9 @@ export class CardAudiences {
58
58
  scope: PromptScope | undefined,
59
59
  minTier: Tier,
60
60
  named?: readonly string[],
61
+ speakerOnly = false,
61
62
  ): Promise<Audience | undefined> {
63
+ if (speakerOnly) return this.#speakers(scope, minTier);
62
64
  let approvers = named;
63
65
  // A private conversation's person is the only one who answers, so a call naming others is theirs only if named or an owner.
64
66
  if (approvers && scope?.escalate === "none") {
@@ -78,6 +80,51 @@ export class CardAudiences {
78
80
  );
79
81
  }
80
82
 
83
+ /**
84
+ * The card of a call that is its speaker's alone (`ApprovalOptions.speakerOnly`): the scope's
85
+ * principal on any of their Discord identities still theirs when they press, at the tier the
86
+ * call needs, checked again then, and no owner as such, whatever the scope escalates to. The
87
+ * primary owner answers their own card, and where there is no identity service the primary owner
88
+ * is the only speaker who has one. Undefined, so the call is unavailable at once, when there is
89
+ * no scope, or the speaker is below the tier or has no Discord identity.
90
+ */
91
+ async #speakers(
92
+ scope: PromptScope | undefined,
93
+ minTier: Tier,
94
+ ): Promise<Audience | undefined> {
95
+ if (!scope) return undefined;
96
+ const { ownerId, identity } = this.#options;
97
+ if (!identity) {
98
+ // Without an identity service the primary owner is the only one who answers, and the only speaker who can.
99
+ if (scope.speakerId !== ownerId) return undefined;
100
+ return {
101
+ allows: async (user) => user.id === ownerId,
102
+ mentions: [ownerId],
103
+ note: "",
104
+ refusal: messages().cardOwnerOnly,
105
+ approver: (user) => this.#approver(user),
106
+ };
107
+ }
108
+ const own = await this.#ownIdentities(scope, minTier);
109
+ if (!own) return undefined;
110
+ const mentions = own.includes(scope.speakerId) ? [scope.speakerId] : own;
111
+ const primary = own.includes(ownerId);
112
+ return {
113
+ allows: async (user) =>
114
+ own.includes(user.id) &&
115
+ // Read the links again: one moved or unlinked since the card was posted is no longer theirs.
116
+ (await this.#stillOwn(scope.principalId, user.id)) &&
117
+ ((primary && user.id === ownerId) ||
118
+ (await this.#owners.holds(user, minTier, scope.principalId))),
119
+ mentions,
120
+ note: primary ? "" : messages().cardApproversNote(...mentions),
121
+ refusal: primary
122
+ ? messages().cardOwnerOnly
123
+ : messages().cardApproversRefusal,
124
+ approver: (user) => this.#approver(user),
125
+ };
126
+ }
127
+
81
128
  /** Who may answer a question; undefined when no one may. */
82
129
  question(scope: PromptScope | undefined): Promise<Audience | undefined> {
83
130
  return this.#audience(scope, undefined, {
@@ -141,6 +141,7 @@ export class OwnerCards implements InteractionModule {
141
141
  scope,
142
142
  minTier,
143
143
  options?.approvers,
144
+ options?.speakerOnly === true,
144
145
  );
145
146
  // A private conversation's call above its person's tier is no one's to approve.
146
147
  if (!audience) return "unavailable";
package/src/core/holds.ts CHANGED
@@ -19,8 +19,20 @@ export interface HoldContext {
19
19
 
20
20
  /**
21
21
  * What a hold that runs in a turn answers with: the description of the call, or the description
22
- * as `text` with a `copy` the owner's approval card offers beside it (see `ApprovalDetails.copy`)
23
- * and a `supersede` signal to withdraw the call by.
22
+ * as `text` with a `copy` the owner's approval card offers beside it (see `ApprovalDetails.copy`),
23
+ * a `supersede` signal to withdraw the call by, and `approval: "speaker"` to make the card the
24
+ * speaker's alone.
25
+ *
26
+ * With `approval: "speaker"` only the speaker whose turn made the call may answer its card, on
27
+ * any of their identities the surface knows and at the call's tier; no owner may answer it as an
28
+ * owner (an owner who is the speaker answers as the speaker). A speaker below the call's tier, a
29
+ * turn with no speaker, or a surface that cannot tell who the speaker is gets `unavailable` at
30
+ * once and the call stays held. Combined with approvers the card is still the speaker's alone,
31
+ * and the runtime accepts their press only when the approvers name them or they are an owner;
32
+ * otherwise the call stays held. It governs the card only: a message that confirms the held call
33
+ * is judged as for any call, so a tool that must act on the speaker's press alone checks
34
+ * `ToolTurn.approvedPrompt` and `ToolTurn.approvedBy` in `run`. Anything but `"speaker"` leaves
35
+ * the card as every card is.
24
36
  *
25
37
  * Aborting `supersede` withdraws the call the hold held, when the plugin no longer wants it
26
38
  * (a newer request replaced it, its subject is gone): its approval card is closed, also after
@@ -39,6 +51,7 @@ export type HoldDescription =
39
51
  text: string;
40
52
  copy?: NonNullable<ApprovalDetails["copy"]>;
41
53
  supersede?: AbortSignal;
54
+ approval?: "speaker";
42
55
  };
43
56
 
44
57
  /**
@@ -95,6 +95,16 @@ export interface ApprovalOptions {
95
95
  * conversation an approval it does not name is not accepted for a call that names approvers.
96
96
  */
97
97
  approvers?: readonly string[];
98
+ /**
99
+ * When true the card is the scope's speaker's alone: they answer it on any of their identities
100
+ * the surface knows, at the call's `minTier` checked again when they answer, and the owners do
101
+ * not, whatever the scope escalates to. Without a speaker, below the tier, or on a surface that
102
+ * cannot tell, it resolves `unavailable` at once. With `approvers`, the speaker's press is
103
+ * accepted only if they are named (the runtime refuses the rest). A surface that cannot honour
104
+ * it must resolve `unavailable` rather than show the card to the owners. Absent or false leaves
105
+ * the card as every card is.
106
+ */
107
+ speakerOnly?: boolean;
98
108
  /**
99
109
  * Called with who approved the call, before the approval resolves `approved`; not called for a
100
110
  * refusal, or by a surface that does not know who answered.
@@ -1,20 +1,12 @@
1
- import { mkdirSync } from "node:fs";
2
- import {
3
- createAgentSession,
4
- DefaultResourceLoader,
5
- type ModelRuntime,
6
- SessionManager,
7
- SettingsManager,
8
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
9
2
  import { PluginError } from "../../errors.ts";
10
3
  import {
11
4
  formatModelRef,
12
5
  type ModelRef,
13
6
  type ThinkingLevel,
14
7
  } from "../../models.ts";
15
- import { runWorkerTask } from "../../runtime/worker-task.ts";
16
- import { activeToolsExtension } from "../../shared/active-tools.ts";
17
8
  import { packageDir } from "../../shared/package-dir.ts";
9
+ import { runWorkerSession } from "./worker-session.ts";
18
10
 
19
11
  const WEB_TOOLS = ["web_search", "fetch_content", "get_search_content"];
20
12
 
@@ -50,39 +42,19 @@ export class WebResearchWorker {
50
42
  constructor(options: WebResearchWorkerOptions) {
51
43
  this.#options = options;
52
44
  this.#webAccessPath = webAccessDir();
53
- mkdirSync(options.workDir, { recursive: true });
54
45
  }
55
46
 
56
- async run(task: string, signal: AbortSignal): Promise<string> {
47
+ run(task: string, signal: AbortSignal): Promise<string> {
57
48
  const { modelRuntime, agentDir, workDir, model, thinking } = this.#options;
58
- const resourceLoader = new DefaultResourceLoader({
59
- cwd: workDir,
60
- agentDir,
61
- noExtensions: true,
62
- noSkills: true,
63
- noPromptTemplates: true,
64
- noThemes: true,
65
- noContextFiles: true,
66
- additionalExtensionPaths: [this.#webAccessPath],
67
- extensionFactories: [
68
- { name: "web-only", factory: activeToolsExtension(() => WEB_TOOLS) },
69
- ],
70
- appendSystemPrompt: [WORKER_PROMPT],
71
- });
72
- await resourceLoader.reload();
73
- const { session } = await createAgentSession({
74
- cwd: workDir,
75
- agentDir,
76
- thinkingLevel: thinking,
77
- modelRuntime,
78
- resourceLoader,
79
- sessionManager: SessionManager.inMemory(workDir),
80
- settingsManager: SettingsManager.inMemory({}),
81
- noTools: "builtin",
82
- });
83
- return runWorkerTask(session, {
49
+ return runWorkerSession({
84
50
  modelRuntime,
51
+ agentDir,
52
+ workDir,
85
53
  model: formatModelRef(model),
54
+ thinking,
55
+ prompt: WORKER_PROMPT,
56
+ extensionPaths: [this.#webAccessPath],
57
+ toolNames: WEB_TOOLS,
86
58
  task,
87
59
  signal,
88
60
  aborted: "the worker ran out of time",