pi-roundtable 0.29.1 → 0.30.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,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.30.0] - 2026-11-01
9
+
10
+ ### Added
11
+
12
+ - `ChatCommand`, `Contribution.chatCommands` and `PluginContext.chatCommands()`: a plugin declares the Pi extension commands (`pi.registerCommand`) of its `piPackages` or `sessionTools` that are safe to run from an owner's chat message, as `{ name, description }`. None is listed until a plugin declares it. `clear`, `new` and `skill` are the web app's own and refused, as is a repeated name; `context.chatCommands()` returns every declaration once linked (`NotLinkedError` during setup, also in `testing`). The web app lists them in a room's command menu. See "chatCommands" in docs/plugins.md.
13
+
14
+ ### Fixed
15
+
16
+ - A delegated task's text is sent to its worker as plain text (`promptTask`, with `expandPromptTemplates: false`; `runWorkerTask` uses it too), so a task text the model wrote that begins with `/name` or `/skill:name` can never run an extension command or expand a skill.
17
+ - `SkillRegistry.carried` warns of a carried skill that is missing once, not on every call: opening an agent's room or its command menu no longer repeats the line. It warns again if the skill came back and went.
18
+ - A message that names an extension command or a skill (`/name args`, `/skill:name args`) sent while a turn runs is no longer steered into the turn: Pi refuses to queue an extension command, and the queued expansion of a skill is not the text the run looks for among its leftovers. It waits for its own turn like a message from another speaker, and Pi runs or expands it then.
19
+
20
+ ### Changed
21
+
22
+ - A group round answers only the members a person's message names. When the text (or the quoted text of a reply) mentions one or more members with `@display name` or `@name`, those members answer and nobody is scored, so a high-scoring member that was not named no longer joins. A reply target and a seat holding an action the speaker may approve still answer as before, handoffs by `@` inside a member's reply keep working, and a message that names no one is scored and planned as it was. This applies wherever group rounds run: Discord channels and web rooms.
23
+
24
+ ## [0.29.2] - 2026-10-31
25
+
26
+ - Release for the web package's changes (see its changelog); no core behavior changes.
27
+
8
28
  ## [0.29.1] - 2026-10-31
9
29
 
10
30
  ### Added
package/docs/plugins.md CHANGED
@@ -183,9 +183,9 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
183
183
  | `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
184
184
  | `observe(filter, observer)` | Hears inbound messages without owning or admitting them: [`observe`](#observe-hearing-messages-without-owning-them). Returns a function that stops it; may be called during `setup` |
185
185
  | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(origin, principalId)`, `known(origin, principalId)`, and `notify(origin, principalId, text)`, each through the [delivery router](#where-things-are-delivered) |
186
- | `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
186
+ | `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()`, `chatCommands()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
187
187
 
188
- `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, and `dashboard()` are available from a service's `start`, an event handler, a session tool, or a claim's turn.
188
+ `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()`, and `chatCommands()` are available from a service's `start`, an event handler, a session tool, or a claim's turn.
189
189
 
190
190
  Use `QueuePort` from the main entry for `context.queue`; the kit's `ChannelQueue` is also type-only.
191
191
 
@@ -2333,6 +2333,28 @@ The operator's `toolTiers` setting wins, and tools with no tier assigned require
2333
2333
  If two plugins name the same tool, the host throws a `PluginError` naming both.
2334
2334
  The built-in addons use it: each declares the tiers of its own tools.
2335
2335
 
2336
+ ### `chatCommands`: Pi extension commands the owner may run from chat
2337
+
2338
+ A Pi extension that a package in [`piPackages`](#pipackages-pi-extensions-every-session-loads) or a [`sessionTools`](#sessiontools-the-raw-form-of-tools) extension carries can register a command with `pi.registerCommand(name, { description, handler })`. Pi runs a message that starts with `/` and names a registered command instead of sending it to the model, so an owner's message `/name args` already reaches the handler. Most commands are written for Pi's terminal screen: they open a dialog or replace the session, and do nothing or fail in a chat room.
2339
+
2340
+ The web app's command menu (typing `/` in a room's message box) therefore lists none of them. A plugin opts a command in by declaring it, once it has checked that the handler needs no screen and does not control the session:
2341
+
2342
+ ```ts
2343
+ export default {
2344
+ name: "stats",
2345
+ setup: () => ({
2346
+ piPackages: ["pi-stats"],
2347
+ chatCommands: [{ name: "stats", description: "Show this week's usage" }],
2348
+ }),
2349
+ } satisfies RoundtablePlugin;
2350
+ ```
2351
+
2352
+ - `name` is lowercase letters, digits, dashes and underscores, starting with a letter, written without the slash. `clear`, `new` and `skill` are the app's own (`/clear` and `/new` start a room over, `/skill:<name>` runs a skill the agent carries) and a plugin cannot take them; a name declared twice, by one plugin or two, is a `PluginError`.
2353
+ - `description` is one line for the menu.
2354
+ - Only an owner sees the entry, and only an owner's message can run the command: the text of any other speaker is prefixed with who wrote it (`(Message from …, at the member tier.)`), so Pi never reads it as a command. A schedule's text follows its own header and a delegated task's text is sent to its worker as plain text (templates, skills and commands are not expanded), so neither can reach a command. The handler runs in the host process with the owner's authority, so declare only what you would let any owner trigger from a phone.
2355
+ - A command is sent as a normal message. While a turn runs it is not steered into the turn (Pi refuses to queue a command): it waits for its own turn, as a message from another speaker does, and Pi runs the handler then.
2356
+ - `context.chatCommands()` returns every plugin's declarations in contribution order, once linked; during `setup` it throws `NotLinkedError`. The web app reads it for `GET api/rooms/<room>/commands`.
2357
+
2336
2358
  ### `sessionTools`: the raw form of `tools`
2337
2359
 
2338
2360
  A session tool is a Pi extension placed in every conversation session by its phase: `tools`, `compaction`, or `mcp`.
@@ -3945,7 +3967,7 @@ It returns:
3945
3967
  The harness applies the same checks as the host (a plugin that adds nothing, an unknown part, a clash of names, a tool with no tier), so a mistake fails your test with the message the start would print.
3946
3968
  Run migrations yourself (see the [`migrations`](#migrations-and-contextdatabase-tables-of-your-own) test), call event handlers with their payloads (see [`events`](#events-hear-what-the-core-does)), and call `contribution.prompt`'s `build` to test prompt text (see [`prompt`](#prompt-text-added-to-every-agent-turn)).
3947
3969
  The harness leaves these calls to the test.
3948
- `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` throw `NotLinkedError` during `setup`, as they do in the host.
3970
+ `sessions()`, `conversations`, `surfaces`, `turns`, `dashboard()`, and `chatCommands()` throw `NotLinkedError` during `setup`, as they do in the host.
3949
3971
  After setup, `conversations` routes to the plugin's claims, `surfaces` contains its surfaces, which start with its services, and `turns` runs over the runtime and surfaces.
3950
3972
 
3951
3973
  #### Options
@@ -4203,7 +4225,7 @@ These are the messages as the code writes them, with `<...>` where your names go
4203
4225
  | `plugin <name>: setup is missing. Give the function that returns what the plugin adds.` | Add `setup` |
4204
4226
  | `plugin <name> adds nothing. Give it a part (tools, services, channels, and so on), a migration, or a provider, or remove it.` | Return a part from `setup`, or remove the plugin from `roundtable.config.ts` |
4205
4227
  | `plugin <name>: setup must return an object of the parts it adds; return {} to add none.` | Return an object, not `undefined` |
4206
- | `plugin <name>: setup returned an unknown part "<key>". Did you mean "<closest>"? The parts are services, events, http, holdRules, piPackages, sessionTools, channels, surfaces, personas, backgroundTargets, directChannels, dashboard, tools, seeds, prompt, agentSelection, requiredTools.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
4228
+ | `plugin <name>: setup returned an unknown part "<key>". Did you mean "<closest>"? The parts are services, events, http, holdRules, piPackages, chatCommands, sessionTools, channels, surfaces, personas, backgroundTargets, directChannels, dashboard, tools, seeds, prompt, agentSelection, requiredTools.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
4207
4229
 
4208
4230
  ### Tools
4209
4231
 
@@ -4246,14 +4268,14 @@ These are the messages as the code writes them, with `<...>` where your names go
4246
4268
 
4247
4269
  ### Things used before they are ready
4248
4270
 
4249
- `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` are linked after every plugin is set up.
4271
+ `sessions()`, `conversations`, `surfaces`, `turns`, `dashboard()`, and `chatCommands()` are linked after every plugin is set up.
4250
4272
  Calling one from `setup` fails, and the message says when it becomes ready:
4251
4273
 
4252
4274
  ```text
4253
4275
  plugin <name>: setup failed: session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup. Fix the error, or remove the plugin.
4254
4276
  ```
4255
4277
 
4256
- The same message exists for `conversations` (`Use them from a service's start or from a handler, not during setup.`), for `surfaces` (`chat surfaces are linked once every plugin is set up.`), for `turns` (`conversation turns are linked once every plugin is set up.`), and for `dashboard()`.
4278
+ The same message exists for `conversations` (`Use them from a service's start or from a handler, not during setup.`), for `surfaces` (`chat surfaces are linked once every plugin is set up.`), for `turns` (`conversation turns are linked once every plugin is set up.`), for `dashboard()`, and for `chatCommands()` (`chat commands are linked once every plugin is set up.`).
4257
4279
  Move the call into a service's `start` or an event handler.
4258
4280
 
4259
4281
  `context.services.get(KEY)` before the plugin that provides it has run throws `service <id> is not provided yet; plugin <name> provides it. Register plugin <name> before plugin <yours>.`
@@ -4430,6 +4452,7 @@ Import from the entries listed below; source area files are internal.
4430
4452
  | `BackgroundTurns` | `pi-roundtable` | type |
4431
4453
  | `ChannelClaim` | `pi-roundtable` | type |
4432
4454
  | `ChannelKey` | `pi-roundtable` | type |
4455
+ | `ChatCommand` | `pi-roundtable` | type |
4433
4456
  | `ChatSurface` | `pi-roundtable` | type |
4434
4457
  | `ClassedHold` | `pi-roundtable` | type |
4435
4458
  | `ClientCallError` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.29.1",
3
+ "version": "0.30.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",
@@ -3,7 +3,7 @@ import { type OwnerIdentity, ownerWords } from "../identity.ts";
3
3
  import type { Logger } from "../log.ts";
4
4
  import type { Agent, GroupMessage } from "./agent-store.ts";
5
5
 
6
- /** Members at or above this relevance answer an owner message nobody is named in. */
6
+ /** Members at or above this relevance answer an owner message nobody is named in; a message naming members is answered by them alone. */
7
7
  export const RELEVANCE_THRESHOLD = 0.5;
8
8
  /** Replies one round may hold, handoffs included (spec behavior 29). */
9
9
  export const MAX_ROUND_REPLIES = 8;
@@ -74,8 +74,9 @@ export class GroupTurns {
74
74
  }
75
75
 
76
76
  /**
77
- * One owner message in a group channel: scored, then answered by the chosen members one at
78
- * a time, each seeing every group message since its last turn. Call inside the queue.
77
+ * One owner message in a group channel: answered by the members it names, or else scored and
78
+ * answered by the chosen members, one at a time, each seeing every group message since its last
79
+ * turn. Call inside the queue.
79
80
  */
80
81
  async answerGroup(
81
82
  channel: ChannelKey,
@@ -102,7 +103,8 @@ export class GroupTurns {
102
103
  text,
103
104
  });
104
105
 
105
- const forced = new Set(mentionedMembers(replyText, members));
106
+ const named = mentionedMembers(replyText, members);
107
+ const forced = new Set(named);
106
108
  const repliedTo = members.find((m) => m.displayName === repliedToName);
107
109
  if (repliedTo) forced.add(repliedTo.name);
108
110
  const approvals = new Map<string, boolean>();
@@ -118,8 +120,10 @@ export class GroupTurns {
118
120
  await confirmations.approves(pending, replyText),
119
121
  );
120
122
  }
123
+ // A message that names members is answered by them alone (and by a seat forced by a held
124
+ // approval or a reply target), so nobody is scored.
121
125
  const scores =
122
- forced.size === members.length
126
+ named.length > 0 || forced.size === members.length
123
127
  ? undefined
124
128
  : await scorer.score(members, recent, text);
125
129
  const order = planRound({
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A Pi extension command (one a package or session tool registers with `pi.registerCommand`) that a
3
+ * plugin vouches for as safe to run from a chat message: it needs no screen and does not control
4
+ * the session. Pi runs a message that starts with `/` and names a registered command instead of
5
+ * sending it to the model, so only the owner's message can start one. The room's command menu
6
+ * lists the declared commands to the owner.
7
+ */
8
+ export interface ChatCommand {
9
+ /** Without the slash: lowercase letters, digits, dashes and underscores, starting with a letter. `clear`, `new` and `skill` are the app's own. */
10
+ name: string;
11
+ /** One line for the menu. */
12
+ description: string;
13
+ }
@@ -213,6 +213,13 @@ export class Linkage {
213
213
  );
214
214
  return this.registry.dashboard;
215
215
  },
216
+ chatCommands: () => {
217
+ if (!this.sessions)
218
+ throw new NotLinkedError(
219
+ "chat commands are linked once every plugin is set up. Call chatCommands() from a service's start or from a handler, not during setup.",
220
+ );
221
+ return this.registry.chatCommands;
222
+ },
216
223
  observe: (filter, observer) => this.observers.add(filter, observer),
217
224
  apiKey: async (provider) => this.#options.apiKey?.(provider),
218
225
  listener: (id) => listenerAddress(listeners, id),
@@ -0,0 +1,34 @@
1
+ import type { Logger } from "../../log.ts";
2
+
3
+ /**
4
+ * Warns of a carried skill that is missing once per agent and name. The registry is asked what an
5
+ * agent carries on every room open and menu fetch, so a warning each time would only repeat. A
6
+ * skill that came back is forgotten here, and warned of again if it goes.
7
+ */
8
+ export class WarnedMissing {
9
+ readonly #logger: Logger;
10
+ readonly #warned = new Set<string>();
11
+
12
+ constructor(logger: Logger) {
13
+ this.#logger = logger;
14
+ }
15
+
16
+ missing(
17
+ agent: string,
18
+ skills: readonly { name: string; reason: string }[],
19
+ ): void {
20
+ const key = (name: string) => `${agent}\0${name}`;
21
+ const now = new Set(skills.map((skill) => key(skill.name)));
22
+ for (const warned of this.#warned)
23
+ if (warned.startsWith(`${agent}\0`) && !now.has(warned))
24
+ this.#warned.delete(warned);
25
+ for (const { name, reason } of skills) {
26
+ if (this.#warned.has(key(name))) continue;
27
+ this.#warned.add(key(name));
28
+ this.#logger.warn(
29
+ { agent, skill: name, reason },
30
+ "carried skill missing",
31
+ );
32
+ }
33
+ }
34
+ }
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { AgentError } from "../../domain/errors.ts";
4
4
  import { PluginError } from "../../errors.ts";
5
5
  import type { SkillRegistry } from "../../services.ts";
6
+ import { WarnedMissing } from "./missing-warning.ts";
6
7
  import {
7
8
  readSkillDocument,
8
9
  skillVersions,
@@ -51,10 +52,12 @@ export class StoredSkillRegistry implements SkillRegistry {
51
52
  readonly #packageSkills = new Map<string, PackageSkill>();
52
53
  /** The tail of the document writes, so a hash is checked and the skill written without another save between. */
53
54
  #documents: Promise<unknown> = Promise.resolve();
55
+ readonly #warned: WarnedMissing;
54
56
 
55
57
  /** Throws a PluginError when a package skill has the name of a linked or written one. */
56
58
  constructor(options: SkillRegistryOptions) {
57
59
  this.#options = options;
60
+ this.#warned = new WarnedMissing(options.logger);
58
61
  for (const skill of options.packageSkills ?? []) {
59
62
  const stored = options.store.skill(skill.name);
60
63
  if (stored)
@@ -96,11 +99,7 @@ export class StoredSkillRegistry implements SkillRegistry {
96
99
  /** What an agent carries, built-in skills included. */
97
100
  carried(agent: string): SkillSet {
98
101
  const set = this.resolve(this.#options.store.carriedBy(agent));
99
- for (const { name, reason } of set.missing)
100
- this.#options.logger.warn(
101
- { agent, skill: name, reason },
102
- "carried skill missing",
103
- );
102
+ this.#warned.missing(agent, set.missing);
104
103
  return set;
105
104
  }
106
105
 
@@ -10,6 +10,7 @@ import type {
10
10
  ObserveFilter,
11
11
  QueuePort,
12
12
  } from "./contract/channels.ts";
13
+ import type { ChatCommand } from "./contract/chat-command.ts";
13
14
  import type { Providers, ResolvedProviders } from "./contract/providers.ts";
14
15
  import type { ServiceKey, Services } from "./contract/services.ts";
15
16
  import type { ChatSurface, SurfacePort } from "./contract/surface.ts";
@@ -189,6 +190,8 @@ export interface Contribution {
189
190
  holdRules?: readonly HoldRule[];
190
191
  /** Pi packages every conversation session loads, in contribution order. */
191
192
  piPackages?: readonly string[];
193
+ /** The Pi extension commands of `piPackages` or `sessionTools` that are safe to run from an owner's chat message; none is listed until declared. */
194
+ chatCommands?: readonly ChatCommand[];
192
195
  /** Extensions of every conversation session, placed by phase around the core's own. */
193
196
  sessionTools?: readonly SessionTool[];
194
197
  /** The channels whose conversations the plugin owns. */
@@ -251,6 +254,7 @@ export const CONTRIBUTION_KEYS = [
251
254
  "http",
252
255
  "holdRules",
253
256
  "piPackages",
257
+ "chatCommands",
254
258
  "sessionTools",
255
259
  "channels",
256
260
  "surfaces",
@@ -374,6 +378,8 @@ export interface PluginContext {
374
378
  providers: ResolvedProviders;
375
379
  /** Every plugin's dashboard lines, in contribution order; throws NotLinkedError during setup. */
376
380
  dashboard(): readonly string[];
381
+ /** Every plugin's declared chat commands, in contribution order; throws NotLinkedError during setup. */
382
+ chatCommands(): readonly ChatCommand[];
377
383
  /**
378
384
  * The credential the host's model login holds for a provider, such as `openai-codex`: the same
379
385
  * login the agents use. Resolves to `undefined` when the host has none for that provider, and
@@ -0,0 +1,33 @@
1
+ import type { ChatCommand } from "../contract/chat-command.ts";
2
+ import { PluginError } from "../errors.ts";
3
+
4
+ const CHAT_COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
5
+ /** The commands the web app answers itself, so a declared command cannot take their names. */
6
+ const APP_COMMANDS = ["clear", "new", "skill"];
7
+
8
+ /** Records a plugin's chat command under its name, or throws PluginError for a name that is not allowed or taken. */
9
+ export function claimChatCommand(
10
+ names: Map<string, string>,
11
+ plugin: string,
12
+ command: ChatCommand,
13
+ ): void {
14
+ const { name, description } = command;
15
+ if (typeof name !== "string" || !CHAT_COMMAND_NAME.test(name))
16
+ throw new PluginError(
17
+ `plugin ${plugin}: a chat command needs a name of lowercase letters, digits, dashes and underscores that starts with a letter, such as "stats", and got ${JSON.stringify(name)}.`,
18
+ );
19
+ if (APP_COMMANDS.includes(name))
20
+ throw new PluginError(
21
+ `plugin ${plugin}: the chat command "${name}" is one the app has itself (${APP_COMMANDS.join(", ")}). Pick another name.`,
22
+ );
23
+ if (typeof description !== "string" || description.trim() === "")
24
+ throw new PluginError(
25
+ `plugin ${plugin}: the chat command "${name}" needs a description, one line for the menu.`,
26
+ );
27
+ const other = names.get(name);
28
+ if (other !== undefined)
29
+ throw new PluginError(
30
+ `plugin ${plugin}: chat command "${name}" is already declared by plugin ${other}. Keep one declaration per name.`,
31
+ );
32
+ names.set(name, plugin);
33
+ }
@@ -1,6 +1,7 @@
1
1
  import { CharacterError, resolveCharacter } from "../../characters/index.ts";
2
2
  import type { AgentSeed } from "../agents/agent-store.ts";
3
3
  import type { BackgroundTarget, ChannelClaim } from "../contract/channels.ts";
4
+ import type { ChatCommand } from "../contract/chat-command.ts";
4
5
  import type { ChatSurface } from "../contract/surface.ts";
5
6
  import type { ToolContribution } from "../define.ts";
6
7
  import { NotLinkedError, PluginError } from "../errors.ts";
@@ -33,6 +34,7 @@ import {
33
34
  } from "../sessions.ts";
34
35
  import { TIERS } from "../speakers.ts";
35
36
  import type { ToolTierTable } from "../tool-tiers.ts";
37
+ import { claimChatCommand } from "./chat-commands.ts";
36
38
  import { ServiceRegistry } from "./services.ts";
37
39
  import { surfaceService } from "./surface-service.ts";
38
40
 
@@ -54,6 +56,7 @@ export interface Registry {
54
56
  /** The contributed direct channels, in contribution order. */
55
57
  directChannels: DirectChannelProvider[];
56
58
  dashboard: string[];
59
+ chatCommands: ChatCommand[];
57
60
  tools: ToolContribution[];
58
61
  seeds: AgentSeed[];
59
62
  prompt: PromptSection[];
@@ -75,6 +78,7 @@ export const emptyRegistry = (): Registry => ({
75
78
  backgroundTargets: [],
76
79
  directChannels: [],
77
80
  dashboard: [],
81
+ chatCommands: [],
78
82
  tools: [],
79
83
  seeds: [],
80
84
  prompt: [],
@@ -309,6 +313,7 @@ export async function collectContributions(
309
313
  const directChannelNames = new Names("direct channel");
310
314
  const personaKinds = new Map<string, string>();
311
315
  const targetNames = new Map<string, string>();
316
+ const chatCommandNames = new Map<string, string>();
312
317
  for (const plugin of plugins) {
313
318
  let contribution: Contribution;
314
319
  try {
@@ -347,6 +352,7 @@ export async function collectContributions(
347
352
  backgroundTargets = [],
348
353
  directChannels = [],
349
354
  dashboard = [],
355
+ chatCommands = [],
350
356
  tools = [],
351
357
  seeds = [],
352
358
  prompt = [],
@@ -364,6 +370,7 @@ export async function collectContributions(
364
370
  backgroundTargets,
365
371
  directChannels,
366
372
  dashboard,
373
+ chatCommands,
367
374
  tools,
368
375
  seeds,
369
376
  prompt,
@@ -444,6 +451,9 @@ export async function collectContributions(
444
451
  registry.backgroundTargets.push(...backgroundTargets);
445
452
  registry.directChannels.push(...directChannels);
446
453
  registry.dashboard.push(...dashboard);
454
+ for (const command of chatCommands)
455
+ claimChatCommand(chatCommandNames, plugin.name, command);
456
+ registry.chatCommands.push(...chatCommands);
447
457
  checkSeedCharacters(plugin.name, seeds);
448
458
  registry.seeds.push(...seeds);
449
459
  registry.prompt.push(...prompt);
@@ -53,7 +53,7 @@ import { promptFailed, turnLimits } from "./turn-limits.ts";
53
53
  import { progressReporter } from "./turn-progress.ts";
54
54
  import { turnTools } from "./turn-tools.ts";
55
55
  import { wrapUpTurn } from "./turn-wrapup.ts";
56
- import { workerReport } from "./worker-task.ts";
56
+ import { promptTask, workerReport } from "./worker-task.ts";
57
57
 
58
58
  export {
59
59
  type CoreExtensions,
@@ -434,7 +434,7 @@ export class PiAgentRuntime implements AgentRuntime {
434
434
  (name) => registered.has(name) && this.#tiers.allows(tier, name),
435
435
  );
436
436
  session.setActiveToolsByName([...worker.tools]);
437
- const prompted = withoutReplyFiles(() => session.prompt(task.text));
437
+ const prompted = withoutReplyFiles(() => promptTask(session, task.text));
438
438
  prompted.catch(() => undefined);
439
439
  await Promise.race([prompted, guard.gaveUp]);
440
440
  return workerReport(
@@ -21,7 +21,10 @@ export type SteeringSession = Pick<
21
21
  | "abort"
22
22
  | "prompt"
23
23
  | "waitForIdle"
24
- >;
24
+ > & {
25
+ /** Pi's extension commands: a message that names one runs it instead of reaching the model. */
26
+ extensionRunner?: { getCommand(name: string): unknown };
27
+ };
25
28
 
26
29
  /**
27
30
  * One turn of a Pi session that the owner may steer or stop. Pi checks its queue once more
@@ -83,13 +86,32 @@ export class SteerableRun {
83
86
  }
84
87
  }
85
88
 
86
- /** Adds the text to the running turn; false when it must wait for a turn of its own. */
89
+ /**
90
+ * Whether the text asks Pi for something it expands or runs rather than sends: an extension
91
+ * command (Pi refuses to queue it) or a skill (Pi queues its expansion, which no longer matches
92
+ * the text the leftover check looks for).
93
+ */
94
+ #isCommand(text: string): boolean {
95
+ if (!text.startsWith("/")) return false;
96
+ const name = text.slice(1).split(/\s/, 1)[0] ?? "";
97
+ return (
98
+ name.startsWith("skill:") ||
99
+ this.#session.extensionRunner?.getCommand(name) !== undefined
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Adds the text to the running turn; false when it must wait for a turn of its own. A message
105
+ * that names an extension command or a skill waits too, as a message like any other: Pi runs
106
+ * or expands it when its turn comes.
107
+ */
87
108
  async steer(text: string, images: PromptImages): Promise<boolean> {
88
109
  if (
89
110
  this.#stopped ||
90
111
  this.#done ||
91
112
  !this.#session.isStreaming ||
92
- !this.#canSteer()
113
+ !this.#canSteer() ||
114
+ this.#isCommand(text)
93
115
  )
94
116
  return false;
95
117
  const steer = { text, images };
@@ -25,6 +25,14 @@ export function workerReport(
25
25
  : report;
26
26
  }
27
27
 
28
+ /**
29
+ * Sends a worker its task as plain text. A task is written by a model or a plugin, so it never
30
+ * runs an extension command or expands a skill, whatever it starts with.
31
+ */
32
+ export function promptTask(session: AgentSession, text: string): Promise<void> {
33
+ return session.prompt(text, { expandPromptTemplates: false });
34
+ }
35
+
28
36
  /** The host's model for `<provider>/<id>`; throws ConfigError when it has none. */
29
37
  export function hostModel(runtime: ModelRuntime, model: string) {
30
38
  const ref = parseModelRef(model);
@@ -56,7 +64,7 @@ export async function runWorkerTask(
56
64
  try {
57
65
  // Extension providers such as claude-bridge exist only once the session loaded them.
58
66
  await session.setModel(hostModel(modelRuntime, model));
59
- await session.prompt(task);
67
+ await promptTask(session, task);
60
68
  return workerReport(session.messages, aborted);
61
69
  } finally {
62
70
  signal.removeEventListener("abort", onAbort);
package/src/index.ts CHANGED
@@ -100,6 +100,7 @@ export type {
100
100
  ScheduledOutcome,
101
101
  } from "./core/contract/channels.ts";
102
102
  export { QUEUED_MARK, STEERED_MARK } from "./core/contract/channels.ts";
103
+ export type { ChatCommand } from "./core/contract/chat-command.ts";
103
104
  export type {
104
105
  ImageDrawer,
105
106
  Judge,
package/src/testing.ts CHANGED
@@ -319,6 +319,8 @@ const NOT_LINKED = {
319
319
  "conversation turns are linked once every plugin is set up. Use turns from a service's start or from a handler, not during setup.",
320
320
  dashboard:
321
321
  "dashboard lines are linked once every plugin is set up. Call dashboard() from a service's start or from a handler, not during setup.",
322
+ chatCommands:
323
+ "chat commands are linked once every plugin is set up. Call chatCommands() from a service's start or from a handler, not during setup.",
322
324
  };
323
325
 
324
326
  function unlinked(part: keyof typeof NOT_LINKED): never {
@@ -554,6 +556,8 @@ export async function testPlugin(
554
556
  },
555
557
  providers,
556
558
  dashboard: () => (linked ? registry.dashboard : unlinked("dashboard")),
559
+ chatCommands: () =>
560
+ linked ? registry.chatCommands : unlinked("chatCommands"),
557
561
  apiKey: async (provider) =>
558
562
  Object.hasOwn(options.apiKeys ?? {}, provider)
559
563
  ? options.apiKeys?.[provider]
@@ -630,6 +634,7 @@ export async function testPlugin(
630
634
  personas: registry.personas,
631
635
  backgroundTargets: registry.backgroundTargets,
632
636
  dashboard: registry.dashboard,
637
+ chatCommands: registry.chatCommands,
633
638
  tools: registry.tools,
634
639
  seeds: registry.seeds,
635
640
  prompt: registry.prompt,