pi-roundtable 0.21.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,14 @@ 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
+
8
16
  ## [0.21.0] - 2026-10-20
9
17
 
10
18
  ### 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:
@@ -3162,7 +3195,7 @@ The [pi-roundtable-mcp][mcp-package] workspace, published separately on npm, add
3162
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.
3163
3196
  Install it with `bun add pi-roundtable-mcp`.
3164
3197
 
3165
- 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.
3166
3199
  The [site guide source files][package-guides] describe installation, requirements, configuration, and security considerations:
3167
3200
 
3168
3201
  - [pi-roundtable-drawing][drawing-package]: [site guide source][drawing-guide] · local relationship maps, magic circles, sigils, sacred geometry, and card spreads.
@@ -3170,6 +3203,7 @@ The [site guide source files][package-guides] describe installation, requirement
3170
3203
  - [pi-roundtable-web][web-package]: [site guide source][web-guide] · an owner-only console for conversations, transcripts, and memory notes with live updates.
3171
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.
3172
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.
3173
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).
3174
3208
 
3175
3209
  [drawing-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/drawing/README.md
@@ -3177,17 +3211,20 @@ The [site guide source files][package-guides] describe installation, requirement
3177
3211
  [web-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/web/README.md
3178
3212
  [sandbox-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/sandbox/README.md
3179
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
3180
3215
  [package-guides]: https://github.com/wayne930242/pi-roundtable/tree/master/site/src/content/docs/plugins/
3181
3216
  [drawing-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/drawing-package.mdx
3182
3217
  [coding-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/coding-package.mdx
3183
3218
  [web-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/web-package.mdx
3184
3219
  [sandbox-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/sandbox-package.mdx
3185
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
3186
3222
  [webchat-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/webchat-package.mdx
3187
3223
  [coding-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/coding
3188
3224
  [sandbox-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
3189
3225
  [web-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/web
3190
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
3191
3228
  [mcp-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/mcp
3192
3229
  [codex-images-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/codex-images/plugin.ts
3193
3230
  [dice-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/dice/plugin.ts
@@ -3455,7 +3492,7 @@ The host's `run()` handles startup:
3455
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.
3456
3493
  2. The database is opened and every plugin's migrations run, in plugin order.
3457
3494
  3. Every plugin's `setup` runs, in plugin order.
3458
- 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.
3459
3496
  4. The contributions are linked: tool tiers, hold rules, the session plan, the channel router, and the events.
3460
3497
  From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
3461
3498
  5. Every plugin's `preflight` runs, in plugin order.
@@ -3543,7 +3580,7 @@ These are the messages as the code writes them, with `<...>` where your names go
3543
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 |
3544
3581
  | `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
3545
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 |
3546
- | `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` |
3547
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) |
3548
3585
  | `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
3549
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 |
@@ -3885,8 +3922,12 @@ Import from the entries listed below; source area files are internal.
3885
3922
  | `SessionToolSnapshot` | `pi-roundtable` | type |
3886
3923
  | `SkillCatalogEntry` | `pi-roundtable` | type |
3887
3924
  | `SkillRegistry` | `pi-roundtable` | type |
3925
+ | `WorkerSessions` | `pi-roundtable` | type |
3926
+ | `WorkerSessionRequest` | `pi-roundtable` | type |
3927
+ | `WORKER_SESSIONS` | `pi-roundtable` | value |
3888
3928
  | `SkillSet` | `pi-roundtable` | type |
3889
3929
  | `SkillSource` | `pi-roundtable` | type |
3930
+ | `SkillsConfig` | `pi-roundtable` | type |
3890
3931
  | `Speaker` | `pi-roundtable` | type |
3891
3932
  | `SpeakerMemory` | `pi-roundtable` | type |
3892
3933
  | `StoredAttachment` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.21.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(),
@@ -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",
@@ -0,0 +1,111 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import {
3
+ createAgentSession,
4
+ DefaultResourceLoader,
5
+ type ExtensionFactory,
6
+ type ModelRuntime,
7
+ SessionManager,
8
+ SettingsManager,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import type { ThinkingLevel } from "../../models.ts";
11
+ import { runWorkerTask } from "../../runtime/worker-task.ts";
12
+ import type { WorkerSessionRequest, WorkerSessions } from "../../services.ts";
13
+ import { activeToolsExtension } from "../../shared/active-tools.ts";
14
+
15
+ export interface WorkerSessionOptions {
16
+ /** Shared with the rest of the host, so the Codex login refreshes in one place. */
17
+ modelRuntime: ModelRuntime;
18
+ agentDir: string;
19
+ workDir: string;
20
+ /** `<provider>/<id>`. */
21
+ model: string;
22
+ thinking: ThinkingLevel;
23
+ prompt: string;
24
+ /** Installed Pi extension packages that register tools. */
25
+ extensionPaths?: readonly string[];
26
+ /** Extensions that register tools. */
27
+ extensions?: readonly { name: string; factory: ExtensionFactory }[];
28
+ /** The only tools active in the session; no built-in tool is. */
29
+ toolNames: readonly string[];
30
+ task: string;
31
+ signal: AbortSignal;
32
+ aborted: string;
33
+ }
34
+
35
+ /**
36
+ * Runs one task in a fresh, unsaved session with no built-in tool, no skills, no context files and
37
+ * only the named tools active, and returns the worker's report.
38
+ */
39
+ export async function runWorkerSession(
40
+ options: WorkerSessionOptions,
41
+ ): Promise<string> {
42
+ const { modelRuntime, agentDir, workDir, model, thinking } = options;
43
+ mkdirSync(workDir, { recursive: true });
44
+ const resourceLoader = new DefaultResourceLoader({
45
+ cwd: workDir,
46
+ agentDir,
47
+ noExtensions: true,
48
+ noSkills: true,
49
+ noPromptTemplates: true,
50
+ noThemes: true,
51
+ noContextFiles: true,
52
+ additionalExtensionPaths: [...(options.extensionPaths ?? [])],
53
+ extensionFactories: [
54
+ ...(options.extensions ?? []),
55
+ // Last, so it undoes any tool another extension re-activates.
56
+ {
57
+ name: "worker-tools-only",
58
+ factory: activeToolsExtension(() => options.toolNames),
59
+ },
60
+ ],
61
+ appendSystemPrompt: [options.prompt],
62
+ });
63
+ await resourceLoader.reload();
64
+ options.signal.throwIfAborted();
65
+ const { session } = await createAgentSession({
66
+ cwd: workDir,
67
+ agentDir,
68
+ thinkingLevel: thinking,
69
+ modelRuntime,
70
+ resourceLoader,
71
+ sessionManager: SessionManager.inMemory(workDir),
72
+ settingsManager: SettingsManager.inMemory({}),
73
+ noTools: "builtin",
74
+ });
75
+ return runWorkerTask(session, {
76
+ modelRuntime,
77
+ model,
78
+ task: options.task,
79
+ signal: options.signal,
80
+ aborted: options.aborted,
81
+ });
82
+ }
83
+
84
+ /** The `WORKER_SESSIONS` service: worker sessions on the host's model runtime and delegation settings. */
85
+ export function workerSessions(options: {
86
+ modelRuntime: ModelRuntime;
87
+ agentDir: string;
88
+ workDir: string;
89
+ delegation: { model: string; thinking: ThinkingLevel };
90
+ }): WorkerSessions {
91
+ return {
92
+ delegation: options.delegation,
93
+ run: (request: WorkerSessionRequest) =>
94
+ runWorkerSession({
95
+ modelRuntime: options.modelRuntime,
96
+ agentDir: options.agentDir,
97
+ workDir: options.workDir,
98
+ model: request.model ?? options.delegation.model,
99
+ thinking: request.thinking ?? options.delegation.thinking,
100
+ prompt: request.prompt,
101
+ extensions: request.extensions.map((factory, index) => ({
102
+ name: `worker-tools-${index}`,
103
+ factory,
104
+ })),
105
+ toolNames: request.toolNames,
106
+ task: request.task,
107
+ signal: request.signal,
108
+ aborted: request.aborted ?? "the worker ran out of time",
109
+ }),
110
+ };
111
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { packageDir } from "../../shared/package-dir.ts";
5
+
6
+ /**
7
+ * Installs a fake npm package under `<root>/node_modules` with the layout of `pi-workflow-skills`:
8
+ * `"pi": { "skills": ["./skills"] }` and one folder with a SKILL.md per skill.
9
+ */
10
+ export function installPackage(
11
+ root: string,
12
+ name: string,
13
+ skills: Record<string, string>,
14
+ manifest: Record<string, unknown> = { pi: { skills: ["./skills"] } },
15
+ ): void {
16
+ const dir = join(root, "node_modules", name);
17
+ mkdirSync(dir, { recursive: true });
18
+ writeFileSync(
19
+ join(dir, "package.json"),
20
+ JSON.stringify({ name, version: "1.0.0", ...manifest }),
21
+ );
22
+ for (const [skill, description] of Object.entries(skills)) {
23
+ mkdirSync(join(dir, "skills", skill, "playbooks"), { recursive: true });
24
+ writeFileSync(
25
+ join(dir, "skills", skill, "SKILL.md"),
26
+ `---\nname: ${skill}\ndescription: ${description}\n---\n\n# ${skill}\n`,
27
+ );
28
+ }
29
+ }
30
+
31
+ /** Looks packages up from `<root>`, the way a host finds its own dependencies. */
32
+ export const packageDirIn = (root: string) => (name: string) =>
33
+ packageDir(name, pathToFileURL(join(root, "host.js")));
@@ -0,0 +1,121 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, relative as relativePath, resolve } from "node:path";
3
+ import { PluginError } from "../../errors.ts";
4
+ import { packageDir } from "../../shared/package-dir.ts";
5
+ import {
6
+ BUILTIN_SKILLS,
7
+ checkSkillName,
8
+ type PackageSkill,
9
+ readSkillFile,
10
+ } from "./skill-rules.ts";
11
+
12
+ /** The SKILL.md files under `path`: the file itself, or each skill folder found below a directory. */
13
+ function skillFilesAt(path: string): string[] {
14
+ if (statSync(path).isFile()) return [path];
15
+ if (existsSync(join(path, "SKILL.md"))) return [join(path, "SKILL.md")];
16
+ return readdirSync(path, { withFileTypes: true })
17
+ .filter(
18
+ (entry) =>
19
+ entry.isDirectory() &&
20
+ !entry.name.startsWith(".") &&
21
+ entry.name !== "node_modules",
22
+ )
23
+ .flatMap((entry) => skillFilesAt(join(path, entry.name)));
24
+ }
25
+
26
+ /** The `pi.skills` entries of a package manifest, or undefined when it declares none. */
27
+ function manifestSkills(manifest: unknown): string[] | undefined {
28
+ const skills = (manifest as { pi?: { skills?: unknown } } | null)?.pi?.skills;
29
+ return Array.isArray(skills) &&
30
+ skills.length > 0 &&
31
+ skills.every((entry) => typeof entry === "string")
32
+ ? skills
33
+ : undefined;
34
+ }
35
+
36
+ function packageSkillsOf(
37
+ name: string,
38
+ resolveDir: (name: string) => string,
39
+ ): PackageSkill[] {
40
+ let dir: string;
41
+ try {
42
+ dir = resolveDir(name);
43
+ } catch {
44
+ throw new PluginError(
45
+ `config skills.packages: the package "${name}" is not installed. Install it next to the host, or remove it from skills.packages.`,
46
+ );
47
+ }
48
+ let manifest: unknown;
49
+ try {
50
+ manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
51
+ } catch (error) {
52
+ throw new PluginError(
53
+ `config skills.packages: the package "${name}" has no readable package.json (${(error as Error).message}). Reinstall it, or remove it from skills.packages.`,
54
+ );
55
+ }
56
+ const entries = manifestSkills(manifest);
57
+ if (!entries)
58
+ throw new PluginError(
59
+ `config skills.packages: the package "${name}" lists no skills in its package.json "pi.skills". Remove it from skills.packages, or use a package that ships skills.`,
60
+ );
61
+ const files = entries.flatMap((entry) => {
62
+ const path = resolve(dir, entry);
63
+ const relative = relativePath(dir, path);
64
+ if (relative.startsWith("..") || isAbsolute(relative))
65
+ throw new PluginError(
66
+ `config skills.packages: "${name}" lists the skills path "${entry}" outside the package. Use a package whose pi.skills stay inside it.`,
67
+ );
68
+ if (!existsSync(path))
69
+ throw new PluginError(
70
+ `config skills.packages: "${name}" lists the skills path "${entry}", which is not in the installed package. Reinstall the package, or remove it from skills.packages.`,
71
+ );
72
+ return skillFilesAt(path);
73
+ });
74
+ if (files.length === 0)
75
+ throw new PluginError(
76
+ `config skills.packages: the package "${name}" has no SKILL.md under its pi.skills. Remove it from skills.packages, or use a package that ships skills.`,
77
+ );
78
+ return files.map((file) => {
79
+ const read = readSkillFile(file);
80
+ if ("error" in read)
81
+ throw new PluginError(
82
+ `config skills.packages: the skill file ${relativePath(dir, file)} of "${name}" cannot be read (${read.error}). Reinstall the package, or remove it from skills.packages.`,
83
+ );
84
+ try {
85
+ checkSkillName(read.name);
86
+ } catch (error) {
87
+ throw new PluginError(
88
+ `config skills.packages: "${name}" holds a skill that cannot be offered. ${(error as Error).message} Use another version of the package, or remove it from skills.packages.`,
89
+ );
90
+ }
91
+ return { name: read.name, package: name, file };
92
+ });
93
+ }
94
+
95
+ /**
96
+ * The skills of installed npm packages, read from each package's `pi.skills` folders (Pi's
97
+ * manifest). Throws a PluginError naming the fix when a package is not installed or has no
98
+ * skills, or when two packages (or one package twice) offer the same name or a built-in name.
99
+ * A clash with a linked or written skill is found by the registry, which holds those.
100
+ */
101
+ export function discoverPackageSkills(
102
+ packages: readonly string[],
103
+ resolveDir: (name: string) => string = packageDir,
104
+ ): PackageSkill[] {
105
+ const found = new Map<string, PackageSkill>();
106
+ for (const name of new Set(packages))
107
+ for (const skill of packageSkillsOf(name, resolveDir)) {
108
+ const earlier = found.get(skill.name);
109
+ if (earlier)
110
+ throw new PluginError(
111
+ `config skills.packages: the skill "${skill.name}" comes from both "${earlier.package}" and "${skill.package}". Drop one of the packages from skills.packages.`,
112
+ );
113
+ if ((BUILTIN_SKILLS as readonly string[]).includes(skill.name))
114
+ throw new PluginError(
115
+ `config skills.packages: the skill "${skill.name}" of "${skill.package}" has the name of a built-in skill. Drop the package from skills.packages.`,
116
+ );
117
+ found.set(skill.name, skill);
118
+ }
119
+ // pi-lens-ignore: no-sort-without-comparator — skill names sort by code unit, the same order on every machine
120
+ return [...found.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
121
+ }
@@ -3,6 +3,7 @@ import {
3
3
  BUILTIN_SKILLS,
4
4
  cut,
5
5
  MAX_LIST_DESCRIPTION_CHARS,
6
+ type PackageSkill,
6
7
  type ResolvedSkill,
7
8
  type SkillCatalogEntry,
8
9
  type SkillSource,
@@ -17,6 +18,7 @@ export type SkillResolver = (
17
18
  /** Every skill, built-in ones first, with what `list` shows as text (web app spec behavior 15). */
18
19
  export function skillCatalog(
19
20
  store: SkillStore,
21
+ packageSkills: readonly PackageSkill[],
20
22
  resolveOne: SkillResolver,
21
23
  ): SkillCatalogEntry[] {
22
24
  const sources: { name: string; source: SkillSource }[] = [
@@ -24,6 +26,10 @@ export function skillCatalog(
24
26
  name,
25
27
  source: { kind: "builtin" } as const,
26
28
  })),
29
+ ...packageSkills.map((skill) => ({
30
+ name: skill.name,
31
+ source: { kind: "package", package: skill.package } as const,
32
+ })),
27
33
  ...store.skills().map((entry) => ({
28
34
  name: entry.name,
29
35
  source:
@@ -51,6 +57,7 @@ export function skillCatalog(
51
57
  /** Every skill, or those of a group or matching a query (behavior 14). */
52
58
  export function skillListText(
53
59
  store: SkillStore,
60
+ packageSkills: readonly PackageSkill[],
54
61
  resolveOne: SkillResolver,
55
62
  filter: { group?: string; query?: string } = {},
56
63
  ): string {
@@ -72,6 +79,10 @@ export function skillListText(
72
79
  }));
73
80
  const entries = [
74
81
  ...builtins,
82
+ ...packageSkills.map((skill) => ({
83
+ name: skill.name,
84
+ source: `from the package ${skill.package}`,
85
+ })),
75
86
  ...store.skills().map((entry) => ({
76
87
  name: entry.name,
77
88
  source:
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { AgentError } from "../../domain/errors.ts";
4
+ import { PluginError } from "../../errors.ts";
4
5
  import type { SkillRegistry } from "../../services.ts";
5
6
  import { planLinkedSkills } from "./skill-link.ts";
6
7
  import { skillCatalog, skillListText } from "./skill-listing.ts";
@@ -9,6 +10,7 @@ import {
9
10
  checkBody,
10
11
  checkDescription,
11
12
  checkSkillName,
13
+ type PackageSkill,
12
14
  type ResolvedSkill,
13
15
  readSkillFile,
14
16
  type SkillCatalogEntry,
@@ -22,6 +24,7 @@ export {
22
24
  BUILTIN_SKILLS,
23
25
  checkSkillName,
24
26
  MAX_SKILL_BODY_CHARS,
27
+ type PackageSkill,
25
28
  type ResolvedSkill,
26
29
  type SkillCatalogEntry,
27
30
  type SkillRegistryOptions,
@@ -32,9 +35,19 @@ export {
32
35
  /** The skill registry and what each agent carries. */
33
36
  export class StoredSkillRegistry implements SkillRegistry {
34
37
  readonly #options: SkillRegistryOptions;
38
+ readonly #packageSkills = new Map<string, PackageSkill>();
35
39
 
40
+ /** Throws a PluginError when a package skill has the name of a linked or written one. */
36
41
  constructor(options: SkillRegistryOptions) {
37
42
  this.#options = options;
43
+ for (const skill of options.packageSkills ?? []) {
44
+ const stored = options.store.skill(skill.name);
45
+ if (stored)
46
+ throw new PluginError(
47
+ `The skill "${skill.name}" of the package "${skill.package}" clashes with a ${stored.kind} skill of the same name. Drop the package from skills.packages and start, remove the ${stored.kind} skill (${stored.kind === "linked" ? "skill_unlink" : "skill_delete"}), then list the package again.`,
48
+ );
49
+ this.#packageSkills.set(skill.name, skill);
50
+ }
38
51
  }
39
52
 
40
53
  /** Writes every agent-written skill for Pi and removes folders left by deleted ones. */
@@ -105,13 +118,18 @@ export class StoredSkillRegistry implements SkillRegistry {
105
118
 
106
119
  /** Every skill, built-in ones first, with what `list` shows as text (web app spec behavior 15). */
107
120
  catalog(): SkillCatalogEntry[] {
108
- return skillCatalog(this.#options.store, (name) => this.#resolveOne(name));
121
+ return skillCatalog(
122
+ this.#options.store,
123
+ [...this.#packageSkills.values()],
124
+ (name) => this.#resolveOne(name),
125
+ );
109
126
  }
110
127
 
111
128
  /** Every skill, or those of a group or matching a query (behavior 14). */
112
129
  list(filter: { group?: string; query?: string } = {}): string {
113
130
  return skillListText(
114
131
  this.#options.store,
132
+ [...this.#packageSkills.values()],
115
133
  (name) => this.#resolveOne(name),
116
134
  filter,
117
135
  );
@@ -119,7 +137,7 @@ export class StoredSkillRegistry implements SkillRegistry {
119
137
 
120
138
  /** Throws when a name is not registered; built-in names are carried already. */
121
139
  checkRegistered(names: readonly string[]): void {
122
- const unknown = names.filter((name) => !this.#options.store.skill(name));
140
+ const unknown = names.filter((name) => !this.#registered(name));
123
141
  if (unknown.length > 0)
124
142
  throw new AgentError(
125
143
  `Unknown skills: ${unknown.join(", ")}. Find names with skill_list.${unknown.some((n) => (BUILTIN_SKILLS as readonly string[]).includes(n)) ? " Built-in skills are always carried." : ""}`,
@@ -142,6 +160,7 @@ export class StoredSkillRegistry implements SkillRegistry {
142
160
  }
143
161
 
144
162
  async unlink(name: string): Promise<string> {
163
+ this.#refusePackage(name);
145
164
  const entry = this.#entry(name);
146
165
  if (entry.kind !== "linked")
147
166
  throw new AgentError(
@@ -206,7 +225,7 @@ export class StoredSkillRegistry implements SkillRegistry {
206
225
  throw new AgentError("A skill group needs a description.");
207
226
  const unknown = skills.filter(
208
227
  (skill) =>
209
- !this.#options.store.skill(skill) &&
228
+ !this.#registered(skill) &&
210
229
  !(BUILTIN_SKILLS as readonly string[]).includes(skill),
211
230
  );
212
231
  if (unknown.length > 0)
@@ -270,6 +289,8 @@ export class StoredSkillRegistry implements SkillRegistry {
270
289
  const { store, reposDir, writtenDir, builtinDir } = this.#options;
271
290
  if ((BUILTIN_SKILLS as readonly string[]).includes(name))
272
291
  return join(builtinDir, name, "SKILL.md");
292
+ const fromPackage = this.#packageSkills.get(name);
293
+ if (fromPackage) return fromPackage.file;
273
294
  const entry = store.skill(name);
274
295
  if (!entry) return undefined;
275
296
  return entry.kind === "linked"
@@ -288,7 +309,24 @@ export class StoredSkillRegistry implements SkillRegistry {
288
309
  return entry;
289
310
  }
290
311
 
312
+ #registered(name: string): boolean {
313
+ return (
314
+ this.#packageSkills.has(name) ||
315
+ this.#options.store.skill(name) !== undefined
316
+ );
317
+ }
318
+
319
+ /** Package skills change with their package, never here. */
320
+ #refusePackage(name: string): void {
321
+ const fromPackage = this.#packageSkills.get(name);
322
+ if (fromPackage)
323
+ throw new AgentError(
324
+ `${name} comes from the package ${fromPackage.package} and is read-only here. It changes when the package is upgraded.`,
325
+ );
326
+ }
327
+
291
328
  #writable(name: string): Extract<SkillEntry, { kind: "written" }> {
329
+ this.#refusePackage(name);
292
330
  const entry = this.#entry(name);
293
331
  if (entry.kind !== "written")
294
332
  throw new AgentError(
@@ -298,8 +336,14 @@ export class StoredSkillRegistry implements SkillRegistry {
298
336
  }
299
337
 
300
338
  #checkFreeName(name: string): void {
339
+ const fromPackage = this.#packageSkills.get(name);
340
+ if (fromPackage)
341
+ throw new AgentError(
342
+ `A skill named "${name}" already exists: it comes from the package ${fromPackage.package}.`,
343
+ );
301
344
  if (
302
345
  (BUILTIN_SKILLS as readonly string[]).includes(name) ||
346
+ this.#packageSkills.has(name) ||
303
347
  this.#options.store.skill(name)
304
348
  )
305
349
  throw new AgentError(`A skill named "${name}" already exists.`);
@@ -28,7 +28,8 @@ export interface SkillSet {
28
28
  export type SkillSource =
29
29
  | { kind: "builtin" }
30
30
  | { kind: "linked"; repo: string; path: string }
31
- | { kind: "written" };
31
+ | { kind: "written" }
32
+ | { kind: "package"; package: string };
32
33
 
33
34
  /** One skill as the web app lists it; a skill whose file cannot be read has `missing` instead. */
34
35
  export interface SkillCatalogEntry {
@@ -43,6 +44,15 @@ export interface SkillCatalogEntry {
43
44
  carriers: string[];
44
45
  }
45
46
 
47
+ /** A skill an installed npm package offers through its `pi.skills` folders. */
48
+ export interface PackageSkill {
49
+ name: string;
50
+ /** The npm package that holds it. */
51
+ package: string;
52
+ /** Its SKILL.md in the installed package. */
53
+ file: string;
54
+ }
55
+
46
56
  export interface SkillRegistryOptions {
47
57
  store: SkillStore;
48
58
  /** Managed repositories, as `<dir>/<owner>/<repo>`. */
@@ -51,6 +61,8 @@ export interface SkillRegistryOptions {
51
61
  writtenDir: string;
52
62
  /** The release's built-in skills, as `<dir>/<name>/SKILL.md`. */
53
63
  builtinDir: string;
64
+ /** Skills of installed packages, as `discoverPackageSkills` found them at startup. */
65
+ packageSkills?: readonly PackageSkill[];
54
66
  logger: Logger;
55
67
  }
56
68
 
@@ -1,3 +1,4 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
1
2
  import type { Agent, AgentGroup } from "./agents/agent-rules.ts";
2
3
  import type { AvatarMode } from "./agents/agent-tools.ts";
3
4
  import type { TeamStatus } from "./agents/team-status.ts";
@@ -12,7 +13,7 @@ import type { PendingConfirmation } from "./domain/conversation.ts";
12
13
  import type { HttpRoute } from "./http/listeners.ts";
13
14
  import type { IdentityLinks } from "./identity/identity-links.ts";
14
15
  import type { IdentityService } from "./identity/identity-service.ts";
15
- import type { ThinkingSetting } from "./models.ts";
16
+ import type { ThinkingLevel, ThinkingSetting } from "./models.ts";
16
17
  import type {
17
18
  DelegationJob,
18
19
  DelegationOutcome,
@@ -91,6 +92,12 @@ export const BACKGROUND_TURNS: ServiceKey<BackgroundTurns> =
91
92
  export const DELEGATION: ServiceKey<Delegator> = serviceKey<Delegator>(
92
93
  "roundtable.delegation",
93
94
  );
95
+ /**
96
+ * Worker sessions on the host's models: a fresh, unsaved Pi session that has only the tools a
97
+ * plugin gives it. Provided by the `worker-sessions` plugin.
98
+ */
99
+ export const WORKER_SESSIONS: ServiceKey<WorkerSessions> =
100
+ serviceKey<WorkerSessions>("roundtable.worker-sessions");
94
101
  /**
95
102
  * The remembered facts of each speaker. Provided by the `memory` plugin, an addon: read it with
96
103
  * `find` when your plugin works without memory, and with `get` when it cannot.
@@ -267,6 +274,42 @@ export interface Delegator {
267
274
  idle(): Promise<void>;
268
275
  }
269
276
 
277
+ /** One task for a worker session. */
278
+ export interface WorkerSessionRequest {
279
+ /** What the worker is asked to do, as its one prompt. */
280
+ task: string;
281
+ /** Aborting it stops the worker; `run` then rejects. */
282
+ signal: AbortSignal;
283
+ /** Appended to the worker's system prompt: its role, its rules, and any instructions it should follow. */
284
+ prompt: string;
285
+ /** Extensions that register the worker's tools with `pi.registerTool`. */
286
+ extensions: readonly ExtensionFactory[];
287
+ /** The only tools active in the session. The session has no built-in tool, skill, context file or other extension. */
288
+ toolNames: readonly string[];
289
+ /** `<provider>/<id>`; default: the host's delegation model. */
290
+ model?: string;
291
+ /** Default: the host's delegation thinking level. */
292
+ thinking?: ThinkingLevel;
293
+ /** Why an aborted run stopped, as the rejection's message; default "the worker ran out of time". */
294
+ aborted?: string;
295
+ }
296
+
297
+ /**
298
+ * Runs one task in a fresh, unsaved Pi session on the host's model login and returns the worker's
299
+ * final answer. The session is disposed of either way. It has no access to the conversation, the
300
+ * owner's memory or the agents' tools: it can do only what `toolNames` lets it, and the tools a
301
+ * plugin registers for it are that plugin's to bound.
302
+ */
303
+ export interface WorkerSessions {
304
+ /** The model and thinking level a request without its own uses: the host's `delegation` settings. */
305
+ readonly delegation: {
306
+ readonly model: string;
307
+ readonly thinking: ThinkingLevel;
308
+ };
309
+ /** Rejects with an `AgentRunError` when the worker ends without an answer. */
310
+ run(request: WorkerSessionRequest): Promise<string>;
311
+ }
312
+
270
313
  /** Remembered facts, each speaker's own; every agent shares the memory of whoever is speaking. */
271
314
  export interface MemoryStore {
272
315
  /** One speaker's memory; it never reads or changes another's. */
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export type {
39
39
  RoundtableConfig,
40
40
  TierConfig,
41
41
  } from "./core/config/config.ts";
42
+ export type { SkillsConfig } from "./core/config/skills-config.ts";
42
43
  export type {
43
44
  AttachmentPort,
44
45
  AttachmentRefusalCode,
@@ -309,6 +310,8 @@ export type {
309
310
  ScheduleStore,
310
311
  SkillRegistry,
311
312
  SpeakerMemory,
313
+ WorkerSessionRequest,
314
+ WorkerSessions,
312
315
  } from "./core/services.ts";
313
316
  export {
314
317
  AGENTS,
@@ -322,6 +325,7 @@ export {
322
325
  RUNTIME,
323
326
  SCHEDULES,
324
327
  SKILLS,
328
+ WORKER_SESSIONS,
325
329
  } from "./core/services.ts";
326
330
  export type {
327
331
  AgentTurnScope,