@evandu/super-pi 0.0.0-stage → 0.1.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.
Files changed (59) hide show
  1. package/README.md +79 -2
  2. package/agents/coder.md +10 -0
  3. package/agents/critic.md +20 -0
  4. package/agents/patcher.md +23 -0
  5. package/agents/planner.md +18 -0
  6. package/agents/reviewer.md +13 -0
  7. package/agents/scout.md +23 -0
  8. package/agents/super-pi-task/core.ts +485 -0
  9. package/agents/super-pi-task/index.ts +141 -0
  10. package/agents/verifier.md +21 -0
  11. package/bin/super-pi.js +17 -0
  12. package/dist/client/assets/index-BMQsEdio.js +24 -0
  13. package/dist/client/assets/index-CsbZzuMy.css +1 -0
  14. package/dist/client/index.html +14 -0
  15. package/dist/extension.d.ts +15 -0
  16. package/dist/extension.js +111 -0
  17. package/dist/server/agents.d.ts +177 -0
  18. package/dist/server/agents.js +484 -0
  19. package/dist/server/application/catalog-service.d.ts +44 -0
  20. package/dist/server/application/catalog-service.js +94 -0
  21. package/dist/server/application/conversation-service.d.ts +73 -0
  22. package/dist/server/application/conversation-service.js +202 -0
  23. package/dist/server/application/extension-ui-service.d.ts +28 -0
  24. package/dist/server/application/extension-ui-service.js +73 -0
  25. package/dist/server/application/run-service.d.ts +115 -0
  26. package/dist/server/application/run-service.js +341 -0
  27. package/dist/server/application/workspace-service.d.ts +45 -0
  28. package/dist/server/application/workspace-service.js +106 -0
  29. package/dist/server/command-handler.d.ts +22 -0
  30. package/dist/server/command-handler.js +137 -0
  31. package/dist/server/files.d.ts +202 -0
  32. package/dist/server/files.js +629 -0
  33. package/dist/server/http-server.d.ts +20 -0
  34. package/dist/server/http-server.js +313 -0
  35. package/dist/server/index.d.ts +1 -0
  36. package/dist/server/index.js +103 -0
  37. package/dist/server/pi-command.d.ts +65 -0
  38. package/dist/server/pi-command.js +183 -0
  39. package/dist/server/runtime.d.ts +819 -0
  40. package/dist/server/runtime.js +1470 -0
  41. package/dist/server/server-context.d.ts +46 -0
  42. package/dist/server/server-context.js +1 -0
  43. package/dist/server/store.d.ts +424 -0
  44. package/dist/server/store.js +1121 -0
  45. package/dist/server/subagent-runs.d.ts +142 -0
  46. package/dist/server/subagent-runs.js +315 -0
  47. package/dist/server/subagents.d.ts +194 -0
  48. package/dist/server/subagents.js +542 -0
  49. package/dist/server/task-tool.d.ts +196 -0
  50. package/dist/server/task-tool.js +285 -0
  51. package/dist/server/tool-calls.d.ts +62 -0
  52. package/dist/server/tool-calls.js +141 -0
  53. package/dist/server/types.d.ts +920 -0
  54. package/dist/server/types.js +11 -0
  55. package/dist/server/upload.d.ts +37 -0
  56. package/dist/server/upload.js +169 -0
  57. package/dist/server/ws-server.d.ts +45 -0
  58. package/dist/server/ws-server.js +116 -0
  59. package/package.json +115 -4
@@ -0,0 +1,484 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * A guard rather than a cast: it is the only thing that lets `tools:` be checked once and then used as
5
+ * a map everywhere without the compiler complaining. It narrows to the index-signature shape that
6
+ * `object` cannot express on its own.
7
+ */
8
+ const isFrontmatter = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
+ const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
10
+ /**
11
+ * Every tool name Super Pi can know STATICALLY, read out of Pi's own source rather than from memory.
12
+ * Each entry carries the place it came from, because this list is the whole safety net behind
13
+ * `validateToolNames` and it has to be re-checked whenever Pi is upgraded.
14
+ *
15
+ * It is deliberately NOT the 8 built-ins alone. Pi's registry also holds:
16
+ *
17
+ * - `codemode` and `tool_search`, registered by the two built-in extensions
18
+ * (extensions/codemode/tool.ts:50, extensions/tool-search/tool.ts:20). Pi's own CLI help offers
19
+ * `--tools read,bash,codemode,'mcp__radius__*'` as an example (cli/args.ts:409), so treating
20
+ * these as unknown would reject configuration Pi documents.
21
+ * - the three MCP resource tools (core/mcp-servers.ts:31-37).
22
+ * - MCP server tools, named `mcp__<server>__<tool>` (core/mcp-servers.ts:38-41). The server list
23
+ * is runtime state, so those are recognised by PREFIX instead of by name - see `isKnownPiToolName`.
24
+ *
25
+ * Anything else Pi can hold - a third-party extension tool, an SDK-supplied tool
26
+ * (agent-session.ts:3503-3509) - has an arbitrary name that no static list can contain. Those names
27
+ * are indistinguishable from typos by construction, which is the limit of this check; see
28
+ * `validateToolNames` for what is rejected on that account and why.
29
+ */
30
+ export const PI_TOOL_NAMES = [
31
+ // core/tools/index.ts:95-105 - the ToolName union and the allToolNames set.
32
+ "read", "bash", "powershell", "edit", "write", "grep", "find", "ls",
33
+ // Registered by Pi's built-in extensions.
34
+ "codemode", "tool_search",
35
+ // core/mcp-servers.ts:31-37 - reachable on any server that exposes resources.
36
+ "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource",
37
+ ];
38
+ const PI_TOOL_NAME_SET = new Set(PI_TOOL_NAMES);
39
+ /**
40
+ * Whether a name can be a real Pi tool. A name is real if it is one of `PI_TOOL_NAMES`, an MCP
41
+ * server tool, or a PATTERN.
42
+ *
43
+ * Patterns are a real feature, not an escape hatch: Pi turns every `--tools` entry into a matcher
44
+ * that treats a `*` entry as a glob (core/mcp-servers.ts:21-25), and `--tools` is exactly what an
45
+ * agent's `tools` map becomes. Rejecting `mcp__radius__*` would reject Pi's documented syntax.
46
+ */
47
+ export function isKnownPiToolName(name) {
48
+ if (PI_TOOL_NAME_SET.has(name))
49
+ return true;
50
+ if (name.includes("*"))
51
+ return true;
52
+ return name.startsWith("mcp__");
53
+ }
54
+ function editDistance(left, right) {
55
+ const row = Array.from({ length: right.length + 1 }, (_, index) => index);
56
+ for (let i = 1; i <= left.length; i += 1) {
57
+ let diagonal = row[0];
58
+ row[0] = i;
59
+ for (let j = 1; j <= right.length; j += 1) {
60
+ const above = row[j];
61
+ row[j] = left[i - 1] === right[j - 1] ? diagonal : Math.min(diagonal + 1, above + 1, row[j - 1] + 1);
62
+ diagonal = above;
63
+ }
64
+ }
65
+ return row[right.length];
66
+ }
67
+ function closestToolName(name) {
68
+ const ranked = PI_TOOL_NAMES.map((candidate) => ({ candidate, distance: editDistance(name, candidate) })).sort((a, b) => a.distance - b.distance);
69
+ return ranked[0]?.distance <= Math.max(2, Math.floor(name.length / 2)) ? ranked[0].candidate : undefined;
70
+ }
71
+ /**
72
+ * Reports every `tools` key that cannot be a Pi tool name, or undefined when they all can.
73
+ *
74
+ * WHY THIS CHECK EXISTS. Pi's own validator for a tool list (`getToolListError`,
75
+ * core/settings-manager.ts:227-234) only looks at `+name`/`-name` SYNTAX and never asks whether the
76
+ * tool exists. An allowlist naming one misspelled tool therefore passes Pi's validation and then
77
+ * matches nothing, so the agent is registered with NO tools at all and no warning is printed
78
+ * anywhere: the picker shows a working agent that cannot read, search or edit anything.
79
+ *
80
+ * WHAT IS REJECTED, AND WHAT THAT COSTS. A name that is neither statically known, nor `mcp__`-prefixed,
81
+ * nor a pattern is reported as a problem, which takes the whole definition out of the roster. The cost
82
+ * is a false positive on a third-party extension or SDK tool (agent-session.ts:3503-3509), whose names
83
+ * are arbitrary and unknowable from here. That direction is deliberate: a rejected agent is VISIBLE in
84
+ * the picker with the reason and the full known list attached, while an accepted typo is invisible and
85
+ * produces a silently useless agent - the exact failure this check exists to remove.
86
+ */
87
+ function validateToolNames(tools) {
88
+ if (!isFrontmatter(tools))
89
+ return undefined;
90
+ const unknown = Object.keys(tools).filter((name) => name !== "*" && !isKnownPiToolName(name));
91
+ if (!unknown.length)
92
+ return undefined;
93
+ const details = unknown.map((name) => {
94
+ const suggestion = closestToolName(name);
95
+ return `${JSON.stringify(name)}${suggestion ? ` (did you mean ${JSON.stringify(suggestion)}?)` : ""}`;
96
+ });
97
+ return `tools names ${details.join(", ")}, which Pi does not have. Statically known tools: ${PI_TOOL_NAMES.join(", ")}; MCP tools are named mcp__<server>__<tool>, and a "*" in a name is a Pi glob.`;
98
+ }
99
+ /**
100
+ * Splits a definition file into frontmatter and body. The body is the system prompt, exactly as in
101
+ * OpenCode - a file with no frontmatter is not an agent definition, and is reported rather than
102
+ * silently treated as an empty-prompt agent.
103
+ */
104
+ export function splitDefinition(source) {
105
+ const match = FRONTMATTER_BLOCK.exec(source);
106
+ if (!match)
107
+ return undefined;
108
+ return { frontmatter: parseYaml(match[1]), body: source.slice(match[0].length) };
109
+ }
110
+ /**
111
+ * A deliberately small YAML reader: scalars, nested one-level maps, inline `[a, b]` lists and `- item`
112
+ * lists. That is the whole grammar OpenCode agent frontmatter uses (`tools:` is a map, `model:` and
113
+ * `color:` are scalars), and it keeps Super Pi free of a YAML dependency for four fields.
114
+ *
115
+ * Anything it cannot represent is DROPPED rather than guessed: a malformed agent file must fail its
116
+ * own parse and be reported, not load with a field the author thought they set.
117
+ */
118
+ export function parseYaml(text) {
119
+ const root = {};
120
+ const lines = text.split(/\r?\n/);
121
+ let index = 0;
122
+ const indentOf = (line) => line.length - line.trimStart().length;
123
+ /**
124
+ * Drops a trailing `# comment`, respecting quotes. Agent frontmatter carries explanatory comments -
125
+ * especially on the `tools` map, where `"*": false` is worth a line of prose - and without this a
126
+ * colour would come out as `"#4C9AFF" # the agent's brand colour`, which then fails the hex check
127
+ * and silently loses the colour. Quote-aware so a `#` inside a quoted value survives.
128
+ */
129
+ const stripComment = (value) => {
130
+ let quote;
131
+ for (let at = 0; at < value.length; at += 1) {
132
+ const character = value[at];
133
+ if (quote) {
134
+ if (character === quote)
135
+ quote = undefined;
136
+ continue;
137
+ }
138
+ if (character === '"' || character === "'") {
139
+ quote = character;
140
+ continue;
141
+ }
142
+ // Only a `#` preceded by whitespace starts a comment, so `color: #4C9AFF` still parses.
143
+ if (character === "#" && (at === 0 || /\s/.test(value[at - 1])))
144
+ return value.slice(0, at);
145
+ }
146
+ return value;
147
+ };
148
+ const valueAfter = (keyLine) => stripComment(keyLine.slice(keyLine.indexOf(":") + 1)).trim();
149
+ /**
150
+ * Reads whatever follows a key on the deeper-indented lines below it: either a `- item` block list or
151
+ * a nested map, and `undefined` when the next line is not deeper at all.
152
+ *
153
+ * This is the single place a block value is read, because the two shapes are easy to confuse and the
154
+ * failure is silent: treating a `- item` list as a map yields `{}`, and treating a map as a list
155
+ * yields `[]`. Both load as "an empty frontmatter field" rather than as an error.
156
+ */
157
+ const readDeeper = (parentIndent) => {
158
+ const next = lines[index];
159
+ if (next === undefined || next.trim() === "" || indentOf(next) <= parentIndent)
160
+ return undefined;
161
+ if (next.trim().startsWith("- ")) {
162
+ const items = [];
163
+ while (index < lines.length && lines[index].trim().startsWith("- ")) {
164
+ items.push(unquote(lines[index].trim().slice(2).trim()));
165
+ index += 1;
166
+ }
167
+ return items;
168
+ }
169
+ return readMap(parentIndent);
170
+ };
171
+ /** Reads the value belonging to `keyLine`, which the caller has ALREADY stepped past.
172
+ *
173
+ * `keyIndent` is the KEY's OWN indent, deliberately not its parent's. A block value must be indented
174
+ * deeper than the key that owns it; compared against the parent's indent instead, the first scalar in
175
+ * a map would swallow its next SIBLING - `tools:` -> `"*": false` -> `read: true` would parse the
176
+ * boolean as a map containing `read`, which loads as a permission map that reads back empty. */
177
+ const readValue = (keyLine, keyIndent) => {
178
+ const rest = valueAfter(keyLine);
179
+ if (rest.startsWith("["))
180
+ return parseInlineList(rest) ?? "";
181
+ const deeper = readDeeper(keyIndent);
182
+ return deeper === undefined ? parseScalar(rest) : deeper;
183
+ };
184
+ /** Reads `key:` followed by lines indented deeper than it, as a nested map. */
185
+ const readMap = (parentIndent) => {
186
+ const nested = {};
187
+ while (index < lines.length && lines[index].trim() !== "" && indentOf(lines[index]) > parentIndent) {
188
+ const child = lines[index];
189
+ const childColon = child.indexOf(":");
190
+ if (childColon < 0) {
191
+ index += 1;
192
+ continue;
193
+ }
194
+ const childKey = unquote(child.slice(0, childColon).trim());
195
+ index += 1;
196
+ nested[childKey] = readValue(child, indentOf(child));
197
+ }
198
+ return nested;
199
+ };
200
+ while (index < lines.length) {
201
+ const line = lines[index];
202
+ if (!line.trim() || line.trim().startsWith("#")) {
203
+ index += 1;
204
+ continue;
205
+ }
206
+ const colon = line.indexOf(":");
207
+ if (colon < 0) {
208
+ index += 1;
209
+ continue;
210
+ }
211
+ const key = unquote(line.slice(0, colon).trim());
212
+ const indent = indentOf(line);
213
+ // Stepped past the key's own line FIRST, so a block value is read from what actually follows it.
214
+ index += 1;
215
+ root[key] = readValue(line, indent);
216
+ }
217
+ return root;
218
+ }
219
+ const parseInlineList = (value) => {
220
+ if (!value.startsWith("[") || !value.endsWith("]"))
221
+ return undefined;
222
+ return value.slice(1, -1).split(",").map((item) => unquote(item.trim())).filter((item) => item !== "");
223
+ };
224
+ /** Booleans, numbers and quoted strings are the only scalar shapes agent frontmatter uses. */
225
+ function parseScalar(value) {
226
+ if (value === "" || value === "~" || value === "null")
227
+ return "";
228
+ if (value === "true")
229
+ return true;
230
+ if (value === "false")
231
+ return false;
232
+ if (/^-?\d+$/.test(value))
233
+ return Number.parseInt(value, 10);
234
+ return unquote(value);
235
+ }
236
+ function unquote(value) {
237
+ if ((value.startsWith('"') && value.endsWith('"') && value.length > 1) || (value.startsWith("'") && value.endsWith("'") && value.length > 1))
238
+ return value.slice(1, -1);
239
+ return value;
240
+ }
241
+ /**
242
+ * The Pi CLI flags an agent's `tools` map becomes. Returned flat so the caller can push them into the
243
+ * spawn argv in order, and so a test can assert on the exact list.
244
+ *
245
+ * THE TRANSLATION, from OpenCode's semantics (`tools` is a map whose `"*"` entry sets the default for
246
+ * every tool, and other keys override it) to Pi's three flags:
247
+ *
248
+ * tools: { "*": false, read: true } -> ["--tools", "read"] allowlist, everything else denied
249
+ * tools: { "*": false } -> ["--no-tools"] nothing at all
250
+ * tools: { bash: false } -> ["--exclude-tools","bash"] deny one, keep Pi's defaults
251
+ * tools: { "*": true, bash: false } -> ["--exclude-tools","bash"] same, stated explicitly
252
+ * no `tools` key -> [] Pi's own default tool set
253
+ *
254
+ * The allowlist form is a real permission boundary, not a courtesy: Pi filters the tool REGISTRY
255
+ * through the same allowlist (agent-session.ts:3536-3545), so a tool that is not on the list is never
256
+ * registered, never declared to the model, and therefore cannot be called - the model does not even
257
+ * see a schema for it. Naming a tool on the allowlist also activates it (agent-session.ts:3569-3574).
258
+ */
259
+ export function toolFlags(tools, options = {}) {
260
+ const denied = new Set((options.denyTools ?? []).filter((name) => name !== ""));
261
+ // An agent with no `tools:` map runs on Pi's own default set, so there is nothing to translate - but a
262
+ // DENY inherited from a parent still has to become an explicit exclusion, because there is no allowlist
263
+ // here to drop the name from.
264
+ if (!tools || typeof tools !== "object")
265
+ return denied.size ? ["--exclude-tools", [...denied].join(",")] : [];
266
+ const entries = Object.entries(tools).filter(([key]) => key !== "*");
267
+ // An absent `"*"` is OpenCode's default: every tool is allowed unless a key says otherwise.
268
+ const wildcard = tools["*"];
269
+ // An explicit `false` is the author's own refusal and outranks any extra the host is adding.
270
+ const explicit = new Map(entries);
271
+ const extra = (options.extraAllowedTools ?? []).filter((name) => name !== "" && explicit.get(name) !== false);
272
+ // An inherited deny is applied to the DECLARED list first, so `tools: {"read": true, "bash": true}` from
273
+ // a child whose parent denied `bash` becomes an allowlist of `read` - the intersection the child is
274
+ // entitled to, and the whole reason `denyTools` is applied here rather than at the call site.
275
+ const declared = entries.filter(([, on]) => on === true).map(([name]) => name).filter((name) => !denied.has(name));
276
+ const deny = entries.filter(([, on]) => on === false).map(([name]) => name)
277
+ .filter((name) => !declared.includes(name));
278
+ if (wildcard === false) {
279
+ // `{"*": false}` with nothing switched back on is the strongest statement an agent author can make -
280
+ // "this role talks, it does not act" - and an allowlist of nothing is where a host capability stops.
281
+ // Extending it would make `extraAllowedTools` an override of the author's intent rather than a
282
+ // completion of it, and the one agent that most clearly does not want a delegation tool is the one
283
+ // that has just said it wants no tools at all.
284
+ if (!declared.length)
285
+ return ["--no-tools"];
286
+ const allow = [...declared, ...extra.filter((name) => !declared.includes(name) && !denied.has(name))];
287
+ return ["--tools", allow.join(",")];
288
+ }
289
+ // A map with no allowlist is Pi's defaults, so a denied name has to become an explicit exclusion
290
+ // rather than being dropped - there is nothing to drop it FROM.
291
+ const excluded = [...new Set([...deny, ...[...denied].filter((name) => !deny.includes(name))])];
292
+ if (excluded.length)
293
+ return ["--exclude-tools", excluded.join(",")];
294
+ // `{"*": true}`, or an empty map, means "Pi's defaults" - the same as passing no flag at all.
295
+ return [];
296
+ }
297
+ /**
298
+ * Whether the map denies everything, which is `{"*": false}` with nothing switched back on. Split out
299
+ * of `toolFlags` because the picker needs to say "no tools" rather than render an empty allowlist.
300
+ */
301
+ export function deniesAllTools(tools) {
302
+ if (!isFrontmatter(tools))
303
+ return false;
304
+ return tools["*"] === false && !Object.entries(tools).some(([key, on]) => key !== "*" && on === true);
305
+ }
306
+ /** The tools an allowlist names, for display. Empty when the agent adds no allowlist of its own. */
307
+ export function allowedToolNames(tools) {
308
+ if (!isFrontmatter(tools) || tools["*"] !== false)
309
+ return [];
310
+ return Object.entries(tools).filter(([key, on]) => key !== "*" && on === true).map(([name]) => name);
311
+ }
312
+ /** `provider/modelId`, split into the two halves Pi's `set_model` and `--provider/--model` both need. */
313
+ export function parseAgentModel(model) {
314
+ if (typeof model !== "string")
315
+ return undefined;
316
+ // The slash is required: Pi rejects a model id with no provider (rpc-types.ts:33 needs both), and a
317
+ // bare string here would otherwise be stored as a provider with an empty modelId.
318
+ const slash = model.indexOf("/");
319
+ if (slash <= 0 || slash === model.length - 1)
320
+ return undefined;
321
+ return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
322
+ }
323
+ /**
324
+ * Reads every `*.md` in `directory` as an agent definition. The file's base name is the agent id,
325
+ * matching OpenCode, so `agents/triage.md` is the agent `triage` and renaming the file renames it.
326
+ *
327
+ * A file that fails to parse is COLLECTED as a problem instead of throwing: one typo in one agent must
328
+ * not take down the picker and hide every other agent with it.
329
+ *
330
+ * `mode` picks WHICH half of one directory of definitions comes back, and defaults to `primary`. Both
331
+ * halves live in the same `agents/*.md` files - a sub-agent is the same format with `mode: subagent` -
332
+ * so the split is a filter on the parsed field, never a second directory or a second parser. A caller
333
+ * with no argument gets exactly what it always got.
334
+ */
335
+ export async function readAgents(directory, options = {}) {
336
+ const mode = options.mode ?? "primary";
337
+ let entries;
338
+ try {
339
+ entries = await readdir(directory);
340
+ }
341
+ catch (error) {
342
+ if (error.code === "ENOENT")
343
+ return { agents: [], problems: [] };
344
+ throw error;
345
+ }
346
+ const agents = [];
347
+ const problems = [];
348
+ for (const file of entries.filter((name) => name.toLowerCase().endsWith(".md")).sort()) {
349
+ const source = await readFile(join(directory, file), "utf8");
350
+ const parsed = parseDefinition(file, source);
351
+ if ("message" in parsed)
352
+ problems.push({ file, message: parsed.message });
353
+ else
354
+ agents.push(parsed);
355
+ }
356
+ // The half that was NOT asked for is filtered out here rather than marked in the UI: sub-agents are
357
+ // invoked BY an agent and never chosen by a human, so listing one in the picker would promise a role
358
+ // the runtime cannot honour. The remainder is alphabetical by id, so either roster reads as a list
359
+ // rather than as a directory listing.
360
+ return { agents: agents.filter((agent) => agent.mode === mode).sort((a, b) => a.id.localeCompare(b.id)), problems };
361
+ }
362
+ /** Only `#rgb` and `#rrggbb`. A `color:` frontmatter value is rendered as a CSS background, so it has to
363
+ * be a colour and nothing else - accepting an arbitrary string would put author-controlled text into a
364
+ * style declaration. Anything else is dropped rather than sanitised into something misleading. */
365
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
366
+ const color = (value) => (typeof value === "string" && HEX_COLOR.test(value.trim()) ? value.trim() : undefined);
367
+ /**
368
+ * The `task:` decision vocabulary, spelled the way OpenCode spells it in `agent.ts:165-167`, where the
369
+ * `plan` agent declares `task: { general: "deny" }`. `true`/`false` are accepted as the boolean
370
+ * equivalents so a definition can be written either way.
371
+ */
372
+ const TASK_DECISIONS = new Set(["allow", "deny", true, false]);
373
+ /**
374
+ * Reports a malformed `task:` value, or undefined when the map is usable.
375
+ *
376
+ * The same reasoning as `validateToolNames`, applied to the field one step along: `task:` is read by
377
+ * whatever implements dispatch, and a value outside this set cannot be acted on. `deny` is the safe
378
+ * direction, so the check exists to catch a typo (`allew`, `yes`) that would otherwise read as a
379
+ * misspelled permission and quietly mean nothing.
380
+ */
381
+ function validateTaskPolicy(taskPolicy) {
382
+ if (!isFrontmatter(taskPolicy))
383
+ return undefined;
384
+ const bad = Object.entries(taskPolicy).filter(([, decision]) => !TASK_DECISIONS.has(decision));
385
+ if (!bad.length)
386
+ return undefined;
387
+ return `task 的值只能是 allow 或 deny,收到 ${bad.map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join(", ")}`;
388
+ }
389
+ /** Parses one definition file, or returns the reason it is not a usable agent. */
390
+ export function parseDefinition(file, source) {
391
+ const split = splitDefinition(source);
392
+ if (!split)
393
+ return { file, message: "缺少 YAML frontmatter(文件必须以 --- 开头)" };
394
+ const { frontmatter, body } = split;
395
+ const id = file.replace(/\.md$/i, "");
396
+ const mode = typeof frontmatter.mode === "string" ? frontmatter.mode.trim() : "primary";
397
+ if (mode !== "primary" && mode !== "subagent")
398
+ return { file, message: `mode 只能是 primary 或 subagent,收到 ${JSON.stringify(mode)}` };
399
+ const prompt = body.trim();
400
+ if (!prompt)
401
+ return { file, message: "正文为空:正文就是该 agent 的 system prompt" };
402
+ const tools = frontmatter.tools;
403
+ if (tools !== undefined && !isFrontmatter(tools))
404
+ return { file, message: "tools 必须是一个映射,例如 tools:\n \"*\": false\n read: true" };
405
+ const unknownTool = validateToolNames(tools);
406
+ if (unknownTool)
407
+ return { file, message: unknownTool };
408
+ const taskPolicy = frontmatter.task;
409
+ if (taskPolicy !== undefined && !isFrontmatter(taskPolicy))
410
+ return { file, message: "task 必须是一个映射,例如 task:\n \"*\": allow\n scout: deny" };
411
+ const badTaskDecision = validateTaskPolicy(taskPolicy);
412
+ if (badTaskDecision)
413
+ return { file, message: badTaskDecision };
414
+ const model = parseAgentModel(frontmatter.model);
415
+ if (frontmatter.model !== undefined && !model)
416
+ return { file, message: `model 必须是 provider/modelId 形式,收到 ${JSON.stringify(frontmatter.model)}` };
417
+ const promptMode = frontmatter.promptMode === undefined ? "append" : frontmatter.promptMode;
418
+ if (promptMode !== "append" && promptMode !== "replace")
419
+ return { file, message: `promptMode 只能是 append 或 replace,收到 ${JSON.stringify(promptMode)}` };
420
+ return {
421
+ id,
422
+ name: typeof frontmatter.name === "string" && frontmatter.name.trim() ? frontmatter.name.trim() : id,
423
+ mode,
424
+ description: typeof frontmatter.description === "string" ? frontmatter.description.trim() : "",
425
+ color: color(frontmatter.color),
426
+ hidden: frontmatter.hidden === true,
427
+ model,
428
+ thinkingLevel: typeof frontmatter.thinking === "string" && frontmatter.thinking.trim() ? frontmatter.thinking.trim() : undefined,
429
+ promptMode,
430
+ toolFlags: toolFlags(tools),
431
+ /** Which subagents this agent may dispatch, from the frontmatter `task:` map. Absent means no
432
+ * policy is expressed, NOT "allow everything" - the consuming dispatch code decides that, and
433
+ * this file deliberately does not implement the `task` tool itself. */
434
+ taskPolicy,
435
+ /** What the picker shows under the agent name. Empty means "Pi's own default tool set". */
436
+ toolNames: deniesAllTools(tools) ? [] : allowedToolNames(tools),
437
+ tools,
438
+ prompt,
439
+ };
440
+ }
441
+ /**
442
+ * Writes an agent's prompt body to `path` and returns that path, so it can be handed to Pi as
443
+ * `--append-system-prompt <file>` / `--system-prompt <file>`.
444
+ *
445
+ * A FILE, never the prompt text on the command line, and that is not a style choice. Pi resolves an
446
+ * existing path to its contents and anything else to literal text (resource-loader.ts:167-182), and
447
+ * Super Pi spawns Pi through `shell: true` on Windows because Pi is installed as a .cmd shim - so
448
+ * prompt text on argv would travel through cmd.exe, where a quote or an ampersand in an agent's
449
+ * instructions is a metacharacter. Writing the file also means the exact text Pi was given is a
450
+ * file on disk that can be read back as evidence.
451
+ */
452
+ export async function writeAgentPrompt(promptDir, agent) {
453
+ const path = join(promptDir, `${agent.id}.txt`);
454
+ // The directory is created here rather than assumed. `dataDir` exists at startup, but `agent-prompts`
455
+ // lives one level below it and nothing else creates it - and a missing directory would fail the very
456
+ // first turn of the very first conversation that selected an agent.
457
+ await mkdir(promptDir, { recursive: true });
458
+ await writeFile(path, agent.prompt, "utf8");
459
+ return path;
460
+ }
461
+ /**
462
+ * The spawn argv an agent adds, in the order Pi must receive it. Exported so the translation can be
463
+ * asserted without spawning anything, and so a probe can report exactly what was asked for.
464
+ *
465
+ * `options` are the caller's constraints on top of the definition's own `tools:` map - the two that
466
+ * exist are described on `ToolFlagOptions`. They are applied here rather than baked into
467
+ * `AgentDefinition.toolFlags` because they belong to the CALL: `PiRpcRuntime` adds `task` to the primary
468
+ * agent it is about to spawn, and the supervisor strips the parent's denies from the child it is about
469
+ * to spawn, and neither fact is a property of the definition file on disk.
470
+ */
471
+ export async function agentSpawnArgs(promptDir, agent, options = {}) {
472
+ if (!agent)
473
+ return [];
474
+ const args = [];
475
+ // Tools before the prompt: both are startup-time flags, and `--tools` also decides which tools the
476
+ // system prompt describes, so it has to be in effect before Pi builds the prompt at all.
477
+ args.push(...toolFlags(agent.tools, options));
478
+ const promptPath = await writeAgentPrompt(promptDir, agent);
479
+ // `append` keeps Pi's own coding-agent baseline (tool guidance, environment facts) and adds the
480
+ // role on top, which is what Pi's flag is for. `replace` is OpenCode's semantics - the agent's
481
+ // words are the whole system prompt - and is opt-in per agent because it strips that baseline.
482
+ args.push(agent.promptMode === "replace" ? "--system-prompt" : "--append-system-prompt", promptPath);
483
+ return args;
484
+ }
@@ -0,0 +1,44 @@
1
+ import type { AgentRuntime } from "../runtime.js";
2
+ import type { AgentCatalog, ModelCatalog, ModelSelection } from "../types.js";
3
+ import type { EventBroadcaster } from "../server-context.js";
4
+ /**
5
+ * Service managing model catalogs and agent definitions (primary and subagents).
6
+ */
7
+ export declare class CatalogService {
8
+ private readonly runtime;
9
+ private readonly broadcaster;
10
+ private readonly agentsDir;
11
+ constructor(runtime: AgentRuntime, broadcaster: EventBroadcaster, agentsDir: string);
12
+ /**
13
+ * Fetches the model catalog from runtime, optionally tailored to a selected model.
14
+ */
15
+ getCatalog(selection?: ModelSelection): Promise<ModelCatalog>;
16
+ /**
17
+ * Broadcasts the model catalog to connected clients.
18
+ */
19
+ broadcastCatalog(selection?: ModelSelection): Promise<void>;
20
+ /**
21
+ * Reads one catalog filtered by mode at source.
22
+ */
23
+ readAgentCatalog(mode: "primary" | "subagent"): Promise<AgentCatalog>;
24
+ /**
25
+ * Reads primary agent definitions from disk.
26
+ */
27
+ agentCatalog(): Promise<AgentCatalog>;
28
+ /**
29
+ * Broadcasts primary agents to connected clients.
30
+ */
31
+ broadcastAgents(): Promise<void>;
32
+ /**
33
+ * Reads subagent definitions from disk.
34
+ */
35
+ subagentCatalog(): Promise<AgentCatalog>;
36
+ /**
37
+ * Broadcasts subagents to connected clients.
38
+ */
39
+ broadcastSubagents(): Promise<void>;
40
+ /**
41
+ * Helper formatting an error when a requested model/thinking level is not offered by Pi.
42
+ */
43
+ notOffered(what: string, catalog: ModelCatalog): Error;
44
+ }
@@ -0,0 +1,94 @@
1
+ import { readAgents } from "../agents.js";
2
+ function summarize(agent) {
3
+ const { prompt, toolFlags, ...summary } = agent;
4
+ const toolSummary = toolFlags.includes("--no-tools")
5
+ ? "禁用全部工具"
6
+ : agent.toolNames.length
7
+ ? `工具:${agent.toolNames.join("、")}`
8
+ : toolFlags.includes("--exclude-tools")
9
+ ? `Pi 默认工具,去掉 ${toolFlags[toolFlags.indexOf("--exclude-tools") + 1]?.split(",").join("、") ?? ""}`
10
+ : "Pi 默认工具";
11
+ return { ...summary, toolSummary };
12
+ }
13
+ /**
14
+ * Service managing model catalogs and agent definitions (primary and subagents).
15
+ */
16
+ export class CatalogService {
17
+ runtime;
18
+ broadcaster;
19
+ agentsDir;
20
+ constructor(runtime, broadcaster, agentsDir) {
21
+ this.runtime = runtime;
22
+ this.broadcaster = broadcaster;
23
+ this.agentsDir = agentsDir;
24
+ }
25
+ /**
26
+ * Fetches the model catalog from runtime, optionally tailored to a selected model.
27
+ */
28
+ async getCatalog(selection) {
29
+ return this.runtime.catalog(selection);
30
+ }
31
+ /**
32
+ * Broadcasts the model catalog to connected clients.
33
+ */
34
+ async broadcastCatalog(selection) {
35
+ const catalog = await this.getCatalog(selection);
36
+ this.broadcaster.broadcast({ type: "model.catalog", catalog });
37
+ }
38
+ /**
39
+ * Reads one catalog filtered by mode at source.
40
+ */
41
+ async readAgentCatalog(mode) {
42
+ try {
43
+ const scan = await readAgents(this.agentsDir, { mode });
44
+ for (const problem of scan.problems) {
45
+ console.warn(`Super Pi: ${this.agentsDir}/${problem.file} is not a usable agent - ${problem.message}`);
46
+ }
47
+ const agents = scan.agents.filter((agent) => !agent.hidden).map(summarize);
48
+ if (!agents.length) {
49
+ console.warn(`Super Pi: no ${mode} agents found in ${this.agentsDir} (${scan.problems.length} file(s) rejected).`);
50
+ }
51
+ return { source: "directory", agents, problems: scan.problems, directory: this.agentsDir, fetchedAt: new Date().toISOString() };
52
+ }
53
+ catch (error) {
54
+ return {
55
+ source: "unavailable",
56
+ agents: [],
57
+ problems: [],
58
+ directory: this.agentsDir,
59
+ fetchedAt: new Date().toISOString(),
60
+ error: error instanceof Error ? error.message : "unknown error",
61
+ };
62
+ }
63
+ }
64
+ /**
65
+ * Reads primary agent definitions from disk.
66
+ */
67
+ async agentCatalog() {
68
+ return this.readAgentCatalog("primary");
69
+ }
70
+ /**
71
+ * Broadcasts primary agents to connected clients.
72
+ */
73
+ async broadcastAgents() {
74
+ this.broadcaster.broadcast({ type: "agent.catalog", catalog: await this.agentCatalog() });
75
+ }
76
+ /**
77
+ * Reads subagent definitions from disk.
78
+ */
79
+ async subagentCatalog() {
80
+ return this.readAgentCatalog("subagent");
81
+ }
82
+ /**
83
+ * Broadcasts subagents to connected clients.
84
+ */
85
+ async broadcastSubagents() {
86
+ this.broadcaster.broadcast({ type: "subagent.catalog", catalog: await this.subagentCatalog() });
87
+ }
88
+ /**
89
+ * Helper formatting an error when a requested model/thinking level is not offered by Pi.
90
+ */
91
+ notOffered(what, catalog) {
92
+ return new Error(`Pi 当前不提供${what}(列表读取于 ${catalog.fetchedAt}${catalog.error ? `,读取有误:${catalog.error}` : ""})`);
93
+ }
94
+ }