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.
@@ -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
 
@@ -18,6 +18,8 @@ export interface CardAsk {
18
18
  wait: PromptWait;
19
19
  details: ApprovalDetails;
20
20
  approvers: readonly string[] | undefined;
21
+ /** Only the speaker may answer the card; see `HoldDescription`. */
22
+ speakerOnly: boolean;
21
23
  /** The call's watch, when its plugin may withdraw it. */
22
24
  watch: Watch | undefined;
23
25
  }
@@ -41,6 +43,7 @@ export async function askOnCard(
41
43
  const found: Omit<CardAnswer, "answer"> = {};
42
44
  const options: ApprovalOptions = {
43
45
  ...(ask.approvers ? { approvers: ask.approvers } : {}),
46
+ ...(ask.speakerOnly ? { speakerOnly: true } : {}),
44
47
  approvedBy: (by) => {
45
48
  found.approvedBy = by;
46
49
  },
@@ -334,6 +334,7 @@ export class ConfirmationGate {
334
334
  wait: { late: (late) => this.#late(call, late, watch) },
335
335
  details: approvalDetails(call, this.#fileRoot, needing.copy),
336
336
  approvers: call.approverIds,
337
+ speakerOnly: needing.speakerOnly === true,
337
338
  watch,
338
339
  },
339
340
  );
@@ -446,6 +447,7 @@ export class ConfirmationGate {
446
447
  held: call,
447
448
  ...(judged.copy ? { copy: judged.copy } : {}),
448
449
  ...(judged.supersede ? { supersede: judged.supersede } : {}),
450
+ ...(judged.speakerOnly ? { speakerOnly: judged.speakerOnly } : {}),
449
451
  };
450
452
  }
451
453
 
@@ -34,6 +34,8 @@ export type Needing =
34
34
  copy?: NonNullable<ApprovalDetails["copy"]>;
35
35
  /** Aborts when the plugin withdraws the call; see `HoldDescription`. */
36
36
  supersede?: AbortSignal;
37
+ /** The card is the speaker's alone; see `HoldDescription`. */
38
+ speakerOnly?: true;
37
39
  }
38
40
  | { released: Release };
39
41
 
@@ -32,6 +32,8 @@ export interface Judgment {
32
32
  /** Aborts when the plugin withdraws the call; see `HoldDescription`. */
33
33
  supersede?: AbortSignal;
34
34
  approvers?: readonly string[];
35
+ /** The card is the speaker's alone; see `HoldDescription`. */
36
+ speakerOnly?: true;
35
37
  }
36
38
 
37
39
  export type Judged = Judgment | undefined | typeof STOPPED;
@@ -55,13 +57,16 @@ function isSignal(value: unknown): value is AbortSignal {
55
57
  export function described(
56
58
  answer: HoldDescription,
57
59
  tool: string,
58
- ): Pick<Judgment, "action" | "copy" | "supersede"> {
60
+ ): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly"> {
59
61
  if (typeof answer === "string") return { action: answer };
60
62
  if (typeof answer !== "object" || answer === null)
61
63
  return { action: messages().holdGeneric(tool) };
62
- const { text, copy, supersede } = answer;
64
+ const { text, copy, supersede, approval } = answer;
63
65
  if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
64
- const withdrawal = isSignal(supersede) ? { supersede } : {};
66
+ const withdrawal = {
67
+ ...(isSignal(supersede) ? { supersede } : {}),
68
+ ...(approval === "speaker" ? { speakerOnly: true as const } : {}),
69
+ };
65
70
  if (
66
71
  !copy ||
67
72
  typeof copy !== "object" ||
@@ -125,7 +130,12 @@ export function joinApprovers(
125
130
  if (!lookup) return withoutApprovers(answer, tool);
126
131
  const join = (action: Described): Judged | Promise<Judged> => {
127
132
  if (action === STOPPED || action === undefined) return action;
128
- const { action: text, copy, supersede } = described(action, tool);
133
+ const {
134
+ action: text,
135
+ copy,
136
+ supersede,
137
+ speakerOnly,
138
+ } = described(action, tool);
129
139
  const controller = new AbortController();
130
140
  if (turn.signal?.aborted) controller.abort();
131
141
  let named:
@@ -149,6 +159,7 @@ export function joinApprovers(
149
159
  action: text,
150
160
  ...(copy ? { copy } : {}),
151
161
  ...(supersede ? { supersede } : {}),
162
+ ...(speakerOnly ? { speakerOnly } : {}),
152
163
  ...(named ? { approvers: named } : {}),
153
164
  };
154
165
  }
@@ -159,6 +170,7 @@ export function joinApprovers(
159
170
  action: text,
160
171
  ...(copy ? { copy } : {}),
161
172
  ...(supersede ? { supersede } : {}),
173
+ ...(speakerOnly ? { speakerOnly } : {}),
162
174
  ...(approvers ? { approvers } : {}),
163
175
  };
164
176
  },
@@ -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. */
@@ -95,6 +95,7 @@ export async function answeredOnlyByThem(
95
95
  other: SurfaceContractAnswerer | undefined,
96
96
  visibility: "private" | "shared",
97
97
  approvers?: readonly string[],
98
+ speakerOnly = false,
98
99
  ): Promise<void> {
99
100
  const { surface, channel, answer } = running.subject;
100
101
  const prompts = surface.prompts?.(
@@ -114,7 +115,12 @@ export async function answeredOnlyByThem(
114
115
  speakerOf(running.subject).tier,
115
116
  undefined,
116
117
  undefined,
117
- approvers ? { approvers } : undefined,
118
+ approvers || speakerOnly
119
+ ? {
120
+ ...(approvers ? { approvers } : {}),
121
+ ...(speakerOnly ? { speakerOnly } : {}),
122
+ }
123
+ : undefined,
118
124
  )
119
125
  .then((value) => {
120
126
  settled = value;