@jwilger/pi-development-system 0.10.6 → 0.11.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 (34) hide show
  1. package/agents/architect.md +66 -0
  2. package/agents/coder.md +57 -0
  3. package/agents/researcher.md +60 -0
  4. package/agents/reviewer.md +53 -0
  5. package/agents/tasker.md +58 -0
  6. package/agents/writer.md +39 -0
  7. package/extensions/development-system.ts +2 -0
  8. package/package.json +5 -3
  9. package/src/subagents/VENDORED.md +38 -0
  10. package/src/subagents/index.ts +544 -0
  11. package/src/subagents/orch/inherited-tools.ts +85 -0
  12. package/src/subagents/orch/mailbox.ts +262 -0
  13. package/src/subagents/orch/manager.ts +765 -0
  14. package/src/subagents/orch/paths.ts +74 -0
  15. package/src/subagents/orch/prompt.ts +25 -0
  16. package/src/subagents/orch/runtime.ts +784 -0
  17. package/src/subagents/orch/tools.ts +166 -0
  18. package/src/subagents/orch/transcript.ts +187 -0
  19. package/src/subagents/prefs/agent-import.ts +246 -0
  20. package/src/subagents/prefs/config.ts +1029 -0
  21. package/src/subagents/prefs/import-discovery.ts +644 -0
  22. package/src/subagents/prefs/import-instructions.ts +157 -0
  23. package/src/subagents/prefs/models.ts +78 -0
  24. package/src/subagents/prefs/settings.ts +371 -0
  25. package/src/subagents/types.ts +200 -0
  26. package/src/subagents/ui/agent-navigation-editor.ts +210 -0
  27. package/src/subagents/ui/dialog.ts +651 -0
  28. package/src/subagents/ui/import-picker.ts +232 -0
  29. package/src/subagents/ui/live-agent-view.ts +572 -0
  30. package/src/subagents/ui/model-picker.ts +452 -0
  31. package/src/subagents/ui/settings-ui.ts +329 -0
  32. package/src/subagents/ui/status-ui.ts +545 -0
  33. package/src/subagents/ui/thread-tree.ts +135 -0
  34. package/src/subagents/ui/ui.ts +1507 -0
@@ -0,0 +1,1029 @@
1
+ // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
+ import {
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { randomUUID } from "node:crypto";
14
+ import { isMap, isScalar, parseDocument, stringify } from "yaml";
15
+ import { getModelPreferences } from "./models.ts";
16
+ import { THINKING_LEVELS, type AgentType } from "../types.ts";
17
+ import type { ToolFilteringMode } from "./settings.ts";
18
+
19
+ // Pi semantic color tokens, resolved as concrete colors for agent name backgrounds.
20
+ export const AGENT_COLORS = [
21
+ "accent",
22
+ "border",
23
+ "borderAccent",
24
+ "borderMuted",
25
+ "success",
26
+ "error",
27
+ "warning",
28
+ "muted",
29
+ "dim",
30
+ "text",
31
+ "thinkingText",
32
+ "scrollbarTrack",
33
+ "scrollbarThumb",
34
+ "searchMatchText",
35
+ "userMessageText",
36
+ "customMessageText",
37
+ "customMessageLabel",
38
+ "toolTitle",
39
+ "toolOutput",
40
+ "mdHeading",
41
+ "mdLink",
42
+ "mdLinkUrl",
43
+ "mdCode",
44
+ "mdCodeBlock",
45
+ "mdCodeBlockBorder",
46
+ "mdQuote",
47
+ "mdQuoteBorder",
48
+ "mdHr",
49
+ "mdListBullet",
50
+ "toolDiffAdded",
51
+ "toolDiffRemoved",
52
+ "toolDiffContext",
53
+ "syntaxComment",
54
+ "syntaxKeyword",
55
+ "syntaxFunction",
56
+ "syntaxVariable",
57
+ "syntaxString",
58
+ "syntaxNumber",
59
+ "syntaxType",
60
+ "syntaxOperator",
61
+ "syntaxPunctuation",
62
+ "thinkingOff",
63
+ "thinkingMinimal",
64
+ "thinkingLow",
65
+ "thinkingMedium",
66
+ "thinkingHigh",
67
+ "thinkingXhigh",
68
+ "thinkingMax",
69
+ "bashMode",
70
+ ] as const;
71
+
72
+ const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
73
+ const TOOL = /^[a-zA-Z0-9_-]+$/;
74
+ const FIELDS = new Set([
75
+ "name",
76
+ "description",
77
+ "models",
78
+ "model",
79
+ "modelSuggestions",
80
+ "thinkingLevel",
81
+ "color",
82
+ "icon",
83
+ "tools",
84
+ ]);
85
+ const SUBAGENT_MANAGER_DIR = "subagent-manager";
86
+ const AGENTS_DIR = "agents";
87
+
88
+ type AgentScope = "user" | "project";
89
+ type SaveOrigin = Pick<AgentType, "name" | "source" | "filePath">;
90
+
91
+ function errorCode(error: unknown): string | undefined {
92
+ return typeof error === "object" && error !== null && "code" in error
93
+ ? String((error as { code?: unknown }).code)
94
+ : undefined;
95
+ }
96
+
97
+ function lstatIfPresent(
98
+ path: string,
99
+ ): ReturnType<typeof lstatSync> | undefined {
100
+ try {
101
+ return lstatSync(path);
102
+ } catch (error) {
103
+ if (errorCode(error) === "ENOENT") return undefined;
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ function assertNotSymlinkPath(
109
+ path: string,
110
+ ): ReturnType<typeof lstatSync> | undefined {
111
+ const stat = lstatIfPresent(path);
112
+ if (stat?.isSymbolicLink()) throw new Error(`Unsafe symlink path: ${path}`);
113
+ return stat;
114
+ }
115
+
116
+ function validatePreservedDestination(
117
+ filePath: string,
118
+ directory: string,
119
+ extensions: readonly string[] = [".md"],
120
+ ): string {
121
+ const resolved = resolve(filePath);
122
+ if (
123
+ dirname(resolved) !== directory ||
124
+ !extensions.some((extension) => resolved.endsWith(extension))
125
+ )
126
+ throw new Error(`Unsafe agent destination: ${resolved}`);
127
+ const stat = lstatIfPresent(resolved);
128
+ if (!stat?.isFile()) throw new Error(`Unsafe agent destination: ${resolved}`);
129
+ return resolved;
130
+ }
131
+
132
+ function frontmatter(content: string): { yaml: string; body: string } {
133
+ const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m.exec(
134
+ content,
135
+ );
136
+ if (!match || match.index !== 0)
137
+ throw new Error("Expected YAML frontmatter enclosed by --- lines");
138
+ return { yaml: match[1], body: content.slice(match[0].length) };
139
+ }
140
+
141
+ function record(value: unknown, label: string): Record<string, unknown> {
142
+ if (value === null || typeof value !== "object" || Array.isArray(value))
143
+ throw new Error(`${label} must be a mapping`);
144
+ return value as Record<string, unknown>;
145
+ }
146
+
147
+ /** Advisory display names only. Empty means no suggestions; never a model pin. */
148
+ function modelSuggestionNames(value: unknown): string[] {
149
+ if (!Array.isArray(value))
150
+ throw new Error("modelSuggestions must be an array of nonempty display names");
151
+ const suggestions = value.map((entry, index) => {
152
+ if (typeof entry !== "string")
153
+ throw new Error(`modelSuggestions[${index}] must be a nonempty display name`);
154
+ const name = entry.trim();
155
+ if (!name) throw new Error(`modelSuggestions[${index}] must be a nonempty display name`);
156
+ return name;
157
+ });
158
+ if (new Set(suggestions).size !== suggestions.length)
159
+ throw new Error("modelSuggestions contains duplicate display names");
160
+ return [...suggestions];
161
+ }
162
+
163
+ function toolNames(value: unknown, label: string): string[] {
164
+ if (
165
+ !Array.isArray(value) ||
166
+ value.some((name) => typeof name !== "string" || !TOOL.test(name))
167
+ ) {
168
+ throw new Error(
169
+ `${label} must be an array of exact tool names (no wildcards or patterns)`,
170
+ );
171
+ }
172
+ if (new Set(value).size !== value.length)
173
+ throw new Error(`${label} contains duplicate tool names`);
174
+ return [...value];
175
+ }
176
+
177
+ function toolPolicy(value: unknown): NonNullable<AgentType["tools"]> {
178
+ const mapping = record(value, "tools");
179
+ for (const key of Object.keys(mapping))
180
+ if (key !== "allow" && key !== "block")
181
+ throw new Error(`Unknown tools field: ${key}`);
182
+ const result: NonNullable<AgentType["tools"]> = {};
183
+ if (Object.hasOwn(mapping, "allow"))
184
+ result.allow = toolNames(mapping.allow, "tools.allow");
185
+ if (Object.hasOwn(mapping, "block"))
186
+ result.block = toolNames(mapping.block, "tools.block");
187
+ return result;
188
+ }
189
+
190
+ export function parseAgentType(content: string, filePath?: string): AgentType {
191
+ try {
192
+ const { yaml, body } = frontmatter(content);
193
+ const doc = parseDocument(yaml, { uniqueKeys: true });
194
+ if (doc.errors.length)
195
+ throw new Error(doc.errors.map((error) => error.message).join("; "));
196
+ const data = record(doc.toJS({ maxAliasCount: 0 }), "Frontmatter");
197
+ for (const key of Object.keys(data))
198
+ if (!FIELDS.has(key))
199
+ throw new Error(`Unknown frontmatter field: ${key}`);
200
+ if (typeof data.name !== "string" || !NAME.test(data.name))
201
+ throw new Error(
202
+ "name must be a safe identifier using letters, numbers, underscores or hyphens",
203
+ );
204
+ if (typeof data.description !== "string" || !data.description.trim())
205
+ throw new Error("description must be a nonempty string");
206
+ const result: AgentType = {
207
+ name: data.name,
208
+ description: data.description,
209
+ systemPrompt: body,
210
+ };
211
+ const models = getModelPreferences({
212
+ models: data.models,
213
+ model: data.model,
214
+ });
215
+ if (models !== undefined) result.models = models;
216
+ if (Object.hasOwn(data, "modelSuggestions"))
217
+ result.modelSuggestions = modelSuggestionNames(data.modelSuggestions);
218
+ if (Object.hasOwn(data, "thinkingLevel")) {
219
+ if (
220
+ !THINKING_LEVELS.includes(
221
+ data.thinkingLevel as AgentType["thinkingLevel"] & string,
222
+ )
223
+ )
224
+ throw new Error(
225
+ `thinkingLevel must be one of: ${THINKING_LEVELS.join(", ")}`,
226
+ );
227
+ result.thinkingLevel = data.thinkingLevel as AgentType["thinkingLevel"];
228
+ }
229
+ if (Object.hasOwn(data, "color")) {
230
+ if (!AGENT_COLORS.includes(data.color as (typeof AGENT_COLORS)[number]))
231
+ throw new Error(
232
+ `color must be a Pi foreground token: ${AGENT_COLORS.join(", ")}`,
233
+ );
234
+ result.color = data.color as string;
235
+ }
236
+ if (Object.hasOwn(data, "icon")) {
237
+ if (typeof data.icon !== "string" || !/^\p{Co}$/u.test(data.icon))
238
+ throw new Error("icon must be a single literal Nerd Font glyph (Unicode private-use character)");
239
+ result.icon = data.icon;
240
+ }
241
+ if (Object.hasOwn(data, "tools")) result.tools = toolPolicy(data.tools);
242
+ if (filePath !== undefined) result.filePath = filePath;
243
+ return result;
244
+ } catch (error) {
245
+ throw new Error(
246
+ `${filePath ?? "Agent definition"}: ${error instanceof Error ? error.message : String(error)}`,
247
+ );
248
+ }
249
+ }
250
+
251
+ export function serializeAgentType(type: AgentType): string {
252
+ if (typeof type.systemPrompt !== "string")
253
+ throw new Error("systemPrompt must be a string");
254
+ const data: Record<string, unknown> = {
255
+ name: type.name,
256
+ description: type.description,
257
+ };
258
+ const models = getModelPreferences(type);
259
+ if (models !== undefined) data.models = models;
260
+ if (type.modelSuggestions !== undefined)
261
+ data.modelSuggestions = modelSuggestionNames(type.modelSuggestions);
262
+ for (const key of ["thinkingLevel", "color", "icon", "tools"] as const)
263
+ if (type[key] !== undefined) data[key] = type[key];
264
+ const content = `---\n${stringify(data)}---\n${type.systemPrompt}`;
265
+ parseAgentType(content);
266
+ return content;
267
+ }
268
+
269
+ export type AgentSettingsScope = "user" | "project";
270
+ export type AgentCustomizationKind = "fork" | "override";
271
+
272
+ /**
273
+ * Sparse settings parsed from a `<name>.yml` override file. Plain YAML mapping,
274
+ * no frontmatter, no Markdown body. Omitted fields follow the base definition;
275
+ * explicit `null` on an optional field resets it to the base/inherit state.
276
+ */
277
+ export interface AgentSettingsOverride {
278
+ name?: string;
279
+ description?: string;
280
+ models?: string[] | null;
281
+ /** @deprecated Single-model alias; normalized to models. */
282
+ model?: string | null;
283
+ modelSuggestions?: string[] | null;
284
+ thinkingLevel?: AgentType["thinkingLevel"] | null;
285
+ color?: string | null;
286
+ icon?: string | null;
287
+ tools?: NonNullable<AgentType["tools"]> | null;
288
+ }
289
+
290
+ export const AGENT_OVERRIDE_EXTENSIONS = [".yml", ".yaml"] as const;
291
+
292
+ function overrideBaseName(file: string): string | undefined {
293
+ for (const extension of AGENT_OVERRIDE_EXTENSIONS)
294
+ if (file.endsWith(extension)) return file.slice(0, -extension.length);
295
+ return undefined;
296
+ }
297
+
298
+ export function isAgentOverrideFile(file: string): boolean {
299
+ return overrideBaseName(file) !== undefined;
300
+ }
301
+
302
+ function nullableField<T>(value: unknown, validate: (value: unknown) => T): T | null {
303
+ if (value === null) return null;
304
+ return validate(value);
305
+ }
306
+
307
+ /**
308
+ * Parse a settings-only `<name>.yml` override. Rejects prompt bodies and
309
+ * unknown fields; validates shared fields with the same rules as full
310
+ * definitions. `expectedName` (the file basename) must match `name` when set.
311
+ */
312
+ export function parseAgentSettings(
313
+ content: string,
314
+ filePath?: string,
315
+ expectedName?: string,
316
+ ): AgentSettingsOverride {
317
+ const label = filePath ?? "Agent settings override";
318
+ try {
319
+ if (/^\uFEFF?---[ \t]*\r?\n/m.test(content) || content.includes("\n---"))
320
+ throw new Error(
321
+ "Settings overrides are plain YAML with no frontmatter and no Markdown body",
322
+ );
323
+ const doc = parseDocument(content, { uniqueKeys: true });
324
+ if (doc.errors.length)
325
+ throw new Error(doc.errors.map((error) => error.message).join("; "));
326
+ const data = record(doc.toJS({ maxAliasCount: 0 }) ?? {}, "Settings override");
327
+ for (const key of Object.keys(data)) {
328
+ if (key === "systemPrompt" || key === "prompt" || key === "body")
329
+ throw new Error(
330
+ `Settings overrides carry no prompt body; "${key}" belongs in a <name>.md fork`,
331
+ );
332
+ if (!FIELDS.has(key)) throw new Error(`Unknown settings field: ${key}`);
333
+ }
334
+ const result: AgentSettingsOverride = {};
335
+ if (Object.hasOwn(data, "name")) {
336
+ if (typeof data.name !== "string" || !NAME.test(data.name))
337
+ throw new Error(
338
+ "name must be a safe identifier using letters, numbers, underscores or hyphens",
339
+ );
340
+ if (expectedName !== undefined && data.name !== expectedName)
341
+ throw new Error(
342
+ `name ${JSON.stringify(data.name)} does not match override file ${JSON.stringify(expectedName)}`,
343
+ );
344
+ result.name = data.name;
345
+ }
346
+ if (Object.hasOwn(data, "description")) {
347
+ if (typeof data.description !== "string" || !data.description.trim())
348
+ throw new Error("description must be a nonempty string");
349
+ result.description = data.description;
350
+ }
351
+ const hasModels = Object.hasOwn(data, "models");
352
+ const hasModel = Object.hasOwn(data, "model");
353
+ if (hasModels && hasModel)
354
+ throw new Error("Specify either models or model, not both");
355
+ if (hasModels)
356
+ result.models = nullableField(data.models, (value) =>
357
+ getModelPreferences({ models: value }),
358
+ ) as string[] | null | undefined;
359
+ if (hasModel) {
360
+ if (data.model === null) result.model = null;
361
+ else
362
+ result.models = getModelPreferences({ model: data.model }) as string[];
363
+ }
364
+ if (Object.hasOwn(data, "modelSuggestions"))
365
+ result.modelSuggestions = nullableField(data.modelSuggestions, (value) =>
366
+ modelSuggestionNames(value),
367
+ );
368
+ if (Object.hasOwn(data, "thinkingLevel"))
369
+ result.thinkingLevel = nullableField(data.thinkingLevel, (value) => {
370
+ if (!THINKING_LEVELS.includes(value as AgentType["thinkingLevel"] & string))
371
+ throw new Error(
372
+ `thinkingLevel must be one of: ${THINKING_LEVELS.join(", ")}`,
373
+ );
374
+ return value as AgentType["thinkingLevel"];
375
+ });
376
+ if (Object.hasOwn(data, "color"))
377
+ result.color = nullableField(data.color, (value) => {
378
+ if (!AGENT_COLORS.includes(value as (typeof AGENT_COLORS)[number]))
379
+ throw new Error(
380
+ `color must be a Pi foreground token: ${AGENT_COLORS.join(", ")}`,
381
+ );
382
+ return value as string;
383
+ });
384
+ if (Object.hasOwn(data, "icon"))
385
+ result.icon = nullableField(data.icon, (value) => {
386
+ if (typeof value !== "string" || !/^\p{Co}$/u.test(value))
387
+ throw new Error(
388
+ "icon must be a single literal Nerd Font glyph (Unicode private-use character)",
389
+ );
390
+ return value;
391
+ });
392
+ if (Object.hasOwn(data, "tools"))
393
+ result.tools = data.tools === null ? null : toolPolicy(data.tools);
394
+ return result;
395
+ } catch (error) {
396
+ throw new Error(
397
+ `${label}: ${error instanceof Error ? error.message : String(error)}`,
398
+ );
399
+ }
400
+ }
401
+
402
+ export function serializeAgentSettings(override: AgentSettingsOverride): string {
403
+ const data: Record<string, unknown> = {};
404
+ if (override.name !== undefined) data.name = override.name;
405
+ if (override.description !== undefined) data.description = override.description;
406
+ const models = getModelPreferences({
407
+ models: override.models ?? undefined,
408
+ model: override.model ?? undefined,
409
+ } as { models?: unknown; model?: unknown });
410
+ if (override.models === null || override.model === null) data.models = null;
411
+ else if (models !== undefined) data.models = models;
412
+ if (override.modelSuggestions !== undefined)
413
+ data.modelSuggestions =
414
+ override.modelSuggestions === null
415
+ ? null
416
+ : modelSuggestionNames(override.modelSuggestions);
417
+ for (const key of ["thinkingLevel", "color", "icon", "tools"] as const)
418
+ if (override[key] !== undefined) data[key] = override[key];
419
+ const content = `${stringify(data)}`;
420
+ parseAgentSettings(content);
421
+ return content;
422
+ }
423
+
424
+ /** Fields an override may clear back to the base/inherit state with null. */
425
+ const CLEARABLE_OVERRIDE_FIELDS = new Set([
426
+ "models",
427
+ "modelSuggestions",
428
+ "thinkingLevel",
429
+ "color",
430
+ "icon",
431
+ "tools",
432
+ ]);
433
+
434
+ /**
435
+ * Merge a sparse settings override on top of a base definition. Unset fields
436
+ * follow the base; explicit null clears the field. The prompt body always
437
+ * comes from the base.
438
+ */
439
+ export function mergeAgentSettings(
440
+ base: AgentType,
441
+ override: AgentSettingsOverride,
442
+ ): AgentType {
443
+ const merged: AgentType = structuredClone(base);
444
+ if (override.description !== undefined) merged.description = override.description;
445
+ const models = getModelPreferences({
446
+ models: override.models ?? undefined,
447
+ model: override.model ?? undefined,
448
+ } as { models?: unknown; model?: unknown });
449
+ if (override.models === null || override.model === null) {
450
+ delete merged.models;
451
+ delete merged.model;
452
+ } else if (models !== undefined) {
453
+ merged.models = [...models];
454
+ delete merged.model;
455
+ }
456
+ for (const key of [
457
+ "modelSuggestions",
458
+ "thinkingLevel",
459
+ "color",
460
+ "icon",
461
+ "tools",
462
+ ] as const) {
463
+ const value = override[key];
464
+ if (value === undefined) continue;
465
+ if (value === null) delete merged[key];
466
+ else (merged[key] as unknown) = structuredClone(value);
467
+ }
468
+ merged.systemPrompt = base.systemPrompt;
469
+ return merged;
470
+ }
471
+
472
+ /**
473
+ * Diff a draft against its base: only changed settings land in the override
474
+ * file, so untouched fields keep tracking bundled updates. Always includes
475
+ * the agent name for readability.
476
+ */
477
+ export function diffAgentSettings(base: AgentType, draft: AgentType): AgentSettingsOverride {
478
+ const override: AgentSettingsOverride = { name: draft.name };
479
+ if (draft.description !== base.description) override.description = draft.description;
480
+ const baseModels = getModelPreferences(base);
481
+ const draftModels = getModelPreferences(draft);
482
+ const sameModels =
483
+ (baseModels === undefined && draftModels === undefined) ||
484
+ (baseModels !== undefined &&
485
+ draftModels !== undefined &&
486
+ baseModels.length === draftModels.length &&
487
+ baseModels.every((model, index) => model === draftModels[index]));
488
+ if (!sameModels) {
489
+ if (draftModels === undefined) override.models = null;
490
+ else override.models = [...draftModels];
491
+ }
492
+ for (const key of [
493
+ "modelSuggestions",
494
+ "thinkingLevel",
495
+ "color",
496
+ "icon",
497
+ "tools",
498
+ ] as const) {
499
+ const before = base[key];
500
+ const after = draft[key];
501
+ if (JSON.stringify(before ?? null) === JSON.stringify(after ?? null)) continue;
502
+ if (after === undefined) override[key] = null as never;
503
+ else (override[key] as unknown) = structuredClone(after);
504
+ }
505
+ void CLEARABLE_OVERRIDE_FIELDS;
506
+ return override;
507
+ }
508
+
509
+ export function selectTools(
510
+ policy: AgentType["tools"],
511
+ available: string[],
512
+ mode: ToolFilteringMode = "allowed",
513
+ ): string[] {
514
+ const names = new Set(available);
515
+ if (mode === "all") return [...names];
516
+ // Ignored lists do not participate in validation or tool selection.
517
+ const relevantPolicy =
518
+ mode === "all-except-blocked"
519
+ ? policy?.block === undefined
520
+ ? {}
521
+ : { block: policy.block }
522
+ : policy;
523
+ const validated = relevantPolicy === undefined ? {} : toolPolicy(relevantPolicy);
524
+ for (const name of [...(validated.allow ?? []), ...(validated.block ?? [])]) {
525
+ if (!names.has(name)) throw new Error(`Unavailable tool name: ${name}`);
526
+ }
527
+ const allow = mode === "all-except-blocked" ? names : new Set(validated.allow ?? []);
528
+ const block = new Set(validated.block ?? []);
529
+ return [...names].filter((name) => allow.has(name) && !block.has(name));
530
+ }
531
+
532
+ export interface ConfigStoreOptions {
533
+ cwd: string;
534
+ agentDir: string;
535
+ includeProject: boolean;
536
+ bundledDir?: string;
537
+ }
538
+
539
+ export class ConfigStore {
540
+ diagnostics: string[] = [];
541
+ private types = new Map<string, AgentType>();
542
+ private bases = new Map<string, AgentType>();
543
+ private readonly options: ConfigStoreOptions;
544
+
545
+ constructor(options: ConfigStoreOptions) {
546
+ this.options = { ...options };
547
+ this.reload();
548
+ }
549
+
550
+ reload(): void {
551
+ this.types.clear();
552
+ this.bases.clear();
553
+ this.diagnostics = [];
554
+ const layers: [string, NonNullable<AgentType["source"]>][] = [
555
+ [
556
+ this.options.bundledDir ??
557
+ fileURLToPath(new URL("../../../agents/", import.meta.url)),
558
+ "bundled",
559
+ ],
560
+ ...this.scopeDirectories("user").map(
561
+ (directory): [string, NonNullable<AgentType["source"]>] => [
562
+ directory,
563
+ "user",
564
+ ],
565
+ ),
566
+ ];
567
+ if (this.options.includeProject)
568
+ layers.push(
569
+ ...this.scopeDirectories("project").map(
570
+ (directory): [string, NonNullable<AgentType["source"]>] => [
571
+ directory,
572
+ "project",
573
+ ],
574
+ ),
575
+ );
576
+ for (const [directory, source] of layers) {
577
+ let files: string[];
578
+ try {
579
+ const stat = assertNotSymlinkPath(directory);
580
+ if (!stat) continue;
581
+ if (!stat.isDirectory())
582
+ throw new Error(`Agent directory must be a directory: ${directory}`);
583
+ files = readdirSync(directory)
584
+ .filter(
585
+ (file) => file.endsWith(".md") || isAgentOverrideFile(file),
586
+ )
587
+ .sort();
588
+ } catch (error) {
589
+ this.diagnostics.push(`${directory}: ${String(error)}`);
590
+ // An unreadable override layer cannot safely expose lower-precedence policies.
591
+ this.types.clear();
592
+ this.bases.clear();
593
+ continue;
594
+ }
595
+ if (source === "bundled") {
596
+ this.loadBundledLayer(directory, files);
597
+ continue;
598
+ }
599
+ this.applyScopeLayer(directory, source, files);
600
+ }
601
+ }
602
+
603
+ /** Bundled layer: full `<name>.md` definitions only; overrides are rejected. */
604
+ private loadBundledLayer(directory: string, files: string[]): void {
605
+ const blocked = new Set<string>();
606
+ const seenNames = new Set<string>();
607
+ for (const file of files) {
608
+ const filePath = join(directory, file);
609
+ if (isAgentOverrideFile(file)) {
610
+ blocked.add(overrideBaseName(file)!);
611
+ this.diagnostics.push(
612
+ `${filePath}: Settings overrides (<name>.yml) are not allowed in the bundled layer`,
613
+ );
614
+ continue;
615
+ }
616
+ let content = "";
617
+ try {
618
+ if (!lstatSync(filePath).isFile())
619
+ throw new Error(
620
+ "Agent definition must be a regular file, not a symlink",
621
+ );
622
+ content = readFileSync(filePath, "utf8");
623
+ const type = parseAgentType(content, filePath);
624
+ if (seenNames.has(type.name)) {
625
+ blocked.add(type.name);
626
+ this.diagnostics.push(
627
+ `${filePath}: Duplicate agent name in bundled layer: ${type.name}`,
628
+ );
629
+ continue;
630
+ }
631
+ seenNames.add(type.name);
632
+ this.types.set(type.name, { ...type, source: "bundled" });
633
+ } catch (error) {
634
+ blocked.add(file.slice(0, -3));
635
+ // Extract declared names even if another field or duplicate key is malformed.
636
+ try {
637
+ const doc = parseDocument(frontmatter(content).yaml);
638
+ if (isMap(doc.contents))
639
+ for (const pair of doc.contents.items) {
640
+ if (
641
+ isScalar(pair.key) &&
642
+ pair.key.value === "name" &&
643
+ isScalar(pair.value) &&
644
+ typeof pair.value.value === "string"
645
+ )
646
+ blocked.add(pair.value.value);
647
+ }
648
+ } catch {
649
+ /* Filename still provides a fail-closed tombstone. */
650
+ }
651
+ this.diagnostics.push(`${filePath}: ${String(error)}`);
652
+ }
653
+ }
654
+ for (const name of blocked) {
655
+ this.types.delete(name);
656
+ this.bases.delete(name);
657
+ }
658
+ }
659
+
660
+ /**
661
+ * User/project layer: `<name>.md` is a full fork (replaces the base),
662
+ * `<name>.yml` is a settings-only override merged on top of the base.
663
+ * Having both for one name, duplicate declarations, malformed files, or an
664
+ * override without a base fails closed for that agent name.
665
+ */
666
+ private applyScopeLayer(
667
+ directory: string,
668
+ scope: AgentSettingsScope,
669
+ files: string[],
670
+ ): void {
671
+ const blocked = new Set<string>();
672
+ const forks = new Map<string, { type: AgentType; file: string }>();
673
+ const overrides = new Map<
674
+ string,
675
+ { override: AgentSettingsOverride; file: string }
676
+ >();
677
+ const forkNames = new Set<string>();
678
+ const declareBlocked = (name: string) => {
679
+ blocked.add(name);
680
+ };
681
+ for (const file of files) {
682
+ const filePath = join(directory, file);
683
+ const overrideName = overrideBaseName(file);
684
+ if (overrideName !== undefined) {
685
+ if (!NAME.test(overrideName)) {
686
+ declareBlocked(overrideName);
687
+ this.diagnostics.push(`${filePath}: Invalid agent name in override filename`);
688
+ continue;
689
+ }
690
+ let content = "";
691
+ try {
692
+ if (!lstatSync(filePath).isFile())
693
+ throw new Error(
694
+ "Agent settings override must be a regular file, not a symlink",
695
+ );
696
+ content = readFileSync(filePath, "utf8");
697
+ const override = parseAgentSettings(content, filePath, overrideName);
698
+ const key = override.name ?? overrideName;
699
+ if (forkNames.has(key)) {
700
+ declareBlocked(key);
701
+ this.diagnostics.push(
702
+ `${filePath}: Use either ${key}.md (full fork) or ${key}.yml (settings override), not both`,
703
+ );
704
+ continue;
705
+ }
706
+ if (overrides.has(key)) {
707
+ declareBlocked(key);
708
+ this.diagnostics.push(
709
+ `${filePath}: Duplicate agent override in ${scope} layer: ${key}`,
710
+ );
711
+ continue;
712
+ }
713
+ overrides.set(key, { override, file });
714
+ } catch (error) {
715
+ declareBlocked(overrideName);
716
+ try {
717
+ const doc = parseDocument(content);
718
+ if (isMap(doc.contents))
719
+ for (const pair of doc.contents.items) {
720
+ if (
721
+ isScalar(pair.key) &&
722
+ pair.key.value === "name" &&
723
+ isScalar(pair.value) &&
724
+ typeof pair.value.value === "string"
725
+ )
726
+ declareBlocked(pair.value.value);
727
+ }
728
+ } catch {
729
+ /* Filename still provides a fail-closed tombstone. */
730
+ }
731
+ this.diagnostics.push(`${filePath}: ${String(error)}`);
732
+ }
733
+ continue;
734
+ }
735
+ let content = "";
736
+ try {
737
+ if (!lstatSync(filePath).isFile())
738
+ throw new Error(
739
+ "Agent definition must be a regular file, not a symlink",
740
+ );
741
+ content = readFileSync(filePath, "utf8");
742
+ const type = parseAgentType(content, filePath);
743
+ if (overrides.has(type.name)) {
744
+ declareBlocked(type.name);
745
+ this.diagnostics.push(
746
+ `${filePath}: Use either ${type.name}.md (full fork) or ${type.name}.yml (settings override), not both`,
747
+ );
748
+ continue;
749
+ }
750
+ if (forkNames.has(type.name)) {
751
+ declareBlocked(type.name);
752
+ this.diagnostics.push(
753
+ `${filePath}: Duplicate agent name in ${scope} layer: ${type.name}`,
754
+ );
755
+ continue;
756
+ }
757
+ forkNames.add(type.name);
758
+ forks.set(type.name, { type, file });
759
+ } catch (error) {
760
+ declareBlocked(file.slice(0, -3));
761
+ try {
762
+ const doc = parseDocument(frontmatter(content).yaml);
763
+ if (isMap(doc.contents))
764
+ for (const pair of doc.contents.items) {
765
+ if (
766
+ isScalar(pair.key) &&
767
+ pair.key.value === "name" &&
768
+ isScalar(pair.value) &&
769
+ typeof pair.value.value === "string"
770
+ )
771
+ declareBlocked(pair.value.value);
772
+ }
773
+ } catch {
774
+ /* Filename still provides a fail-closed tombstone. */
775
+ }
776
+ this.diagnostics.push(`${filePath}: ${String(error)}`);
777
+ }
778
+ }
779
+ for (const name of [...forks.keys()].filter((name) => overrides.has(name))) {
780
+ declareBlocked(name);
781
+ this.diagnostics.push(
782
+ `${join(directory, forks.get(name)!.file)} and ${join(directory, overrides.get(name)!.file)}: ` +
783
+ `Use either ${name}.md (full fork) or ${name}.yml (settings override), not both`,
784
+ );
785
+ }
786
+ for (const [name, { type, file }] of forks) {
787
+ if (blocked.has(name)) continue;
788
+ const filePath = join(directory, file);
789
+ const base = this.types.get(name);
790
+ const fork: AgentType = {
791
+ ...type,
792
+ source: scope,
793
+ filePath,
794
+ customization: { kind: "fork", scope, filePath },
795
+ baseSource: base?.source,
796
+ baseFilePath: base?.filePath,
797
+ };
798
+ this.bases.set(name, base ? structuredClone(base) : structuredClone(fork));
799
+ this.types.set(name, fork);
800
+ }
801
+ for (const [name, { override, file }] of overrides) {
802
+ if (blocked.has(name)) continue;
803
+ const filePath = join(directory, file);
804
+ const base = this.types.get(name);
805
+ if (!base) {
806
+ declareBlocked(name);
807
+ this.diagnostics.push(
808
+ `${filePath}: Settings override has no base agent named ${JSON.stringify(name)}; ` +
809
+ `create ${name}.md first or remove the override`,
810
+ );
811
+ continue;
812
+ }
813
+ try {
814
+ const merged = mergeAgentSettings(base, { ...override, name });
815
+ this.bases.set(name, structuredClone(base));
816
+ this.types.set(name, {
817
+ ...merged,
818
+ name,
819
+ source: scope,
820
+ filePath,
821
+ customization: { kind: "override", scope, filePath },
822
+ baseSource: base.source,
823
+ baseFilePath: base.filePath,
824
+ });
825
+ } catch (error) {
826
+ declareBlocked(name);
827
+ this.diagnostics.push(`${filePath}: ${String(error)}`);
828
+ }
829
+ }
830
+ for (const name of blocked) {
831
+ this.types.delete(name);
832
+ this.bases.delete(name);
833
+ }
834
+ }
835
+
836
+ list(): AgentType[] {
837
+ return structuredClone(
838
+ [...this.types.values()].sort((a, b) => a.name.localeCompare(b.name)),
839
+ );
840
+ }
841
+
842
+ get(name: string): AgentType {
843
+ const type = this.types.get(name);
844
+ if (!type) throw new Error(`Unknown or invalid agent type: ${name}`);
845
+ return structuredClone(type);
846
+ }
847
+
848
+ canSaveProject(): boolean {
849
+ return this.options.includeProject;
850
+ }
851
+
852
+ private preferredScopeDirectory(scope: AgentScope): string {
853
+ if (scope !== "user" && scope !== "project")
854
+ throw new Error("Invalid agent scope");
855
+ if (scope === "project" && !this.options.includeProject)
856
+ throw new Error("Project agents are not enabled/trusted");
857
+ return scope === "user"
858
+ ? resolve(this.options.agentDir, SUBAGENT_MANAGER_DIR, AGENTS_DIR)
859
+ : resolve(
860
+ this.options.cwd,
861
+ ".pi",
862
+ "agent",
863
+ SUBAGENT_MANAGER_DIR,
864
+ AGENTS_DIR,
865
+ );
866
+ }
867
+
868
+ // Manager-owned storage only. Do not scan legacy Pi agent packages.
869
+ private scopeDirectories(scope: AgentScope): string[] {
870
+ return [this.preferredScopeDirectory(scope)];
871
+ }
872
+
873
+ private destinationBasePaths(scope: AgentScope, directory: string): string[] {
874
+ if (scope === "user") {
875
+ const base = resolve(this.options.agentDir);
876
+ return [base, join(base, SUBAGENT_MANAGER_DIR), directory];
877
+ }
878
+ const base = resolve(this.options.cwd);
879
+ const projectPi = join(base, ".pi");
880
+ const projectAgent = join(projectPi, "agent");
881
+ return [
882
+ base,
883
+ projectPi,
884
+ projectAgent,
885
+ join(projectAgent, SUBAGENT_MANAGER_DIR),
886
+ directory,
887
+ ];
888
+ }
889
+
890
+ /** Base definition under the top customization, for diffing and read-only display. */
891
+ getBase(name: string): AgentType | undefined {
892
+ const base = this.bases.get(name);
893
+ return base ? structuredClone(base) : undefined;
894
+ }
895
+
896
+ private canPreserveDestination(
897
+ name: string,
898
+ scope: AgentScope,
899
+ original: SaveOrigin | undefined,
900
+ kind: AgentCustomizationKind,
901
+ ): boolean {
902
+ if (
903
+ original?.name !== name ||
904
+ original.source !== scope ||
905
+ typeof original.filePath !== "string" ||
906
+ dirname(resolve(original.filePath)) !==
907
+ this.preferredScopeDirectory(scope)
908
+ )
909
+ return false;
910
+ const preserved = resolve(original.filePath);
911
+ if (kind === "fork") return preserved.endsWith(".md");
912
+ return AGENT_OVERRIDE_EXTENSIONS.some((extension) =>
913
+ preserved.endsWith(extension),
914
+ );
915
+ }
916
+
917
+ destination(
918
+ name: string,
919
+ scope: AgentScope,
920
+ original?: SaveOrigin,
921
+ kind: AgentCustomizationKind = "fork",
922
+ ): string {
923
+ if (!NAME.test(name)) throw new Error("Unsafe agent name");
924
+ const directory = this.preferredScopeDirectory(scope);
925
+ if (
926
+ this.canPreserveDestination(name, scope, original, kind) &&
927
+ typeof original?.filePath === "string"
928
+ ) {
929
+ return validatePreservedDestination(
930
+ original.filePath,
931
+ directory,
932
+ kind === "fork" ? [".md"] : [...AGENT_OVERRIDE_EXTENSIONS],
933
+ );
934
+ }
935
+ return resolve(directory, `${name}${kind === "fork" ? ".md" : ".yml"}`);
936
+ }
937
+
938
+ private writeContent(filePath: string, name: string, content: string): void {
939
+ const directory = dirname(filePath);
940
+ mkdirSync(directory, { recursive: true });
941
+ const temporary = join(
942
+ dirname(filePath),
943
+ `.${name}.${randomUUID()}.tmp`,
944
+ );
945
+ try {
946
+ writeFileSync(temporary, content, { flag: "wx", mode: 0o600 });
947
+ renameSync(temporary, filePath);
948
+ } finally {
949
+ if (lstatIfPresent(temporary)) unlinkSync(temporary);
950
+ }
951
+ }
952
+
953
+ private assertWritableDestination(filePath: string, scope: AgentScope): void {
954
+ const directory = dirname(filePath);
955
+ // Reject redirected destination directories and files before writing.
956
+ for (const path of [
957
+ ...this.destinationBasePaths(scope, directory),
958
+ filePath,
959
+ ]) {
960
+ const stat = assertNotSymlinkPath(path);
961
+ if (path === filePath && stat && !stat.isFile())
962
+ throw new Error(`Unsafe agent destination: ${filePath}`);
963
+ }
964
+ }
965
+
966
+ /** Save a full `<name>.md` fork. Replaces the base definition entirely. */
967
+ save(type: AgentType, scope: AgentScope, original?: SaveOrigin): AgentType {
968
+ const content = serializeAgentType(type);
969
+ const validated = parseAgentType(content);
970
+ const filePath = this.destination(validated.name, scope, original, "fork");
971
+ this.assertWritableDestination(filePath, scope);
972
+ this.writeContent(filePath, validated.name, content);
973
+ this.reload();
974
+ return this.get(validated.name);
975
+ }
976
+
977
+ /**
978
+ * Save a settings-only `<name>.yml` override merged on top of the base.
979
+ * The prompt body always comes from the base; only changed settings are
980
+ * written so untouched fields keep tracking bundled updates.
981
+ */
982
+ saveOverride(
983
+ name: string,
984
+ draft: AgentType,
985
+ scope: AgentScope,
986
+ original?: SaveOrigin,
987
+ ): AgentType {
988
+ if (!NAME.test(name)) throw new Error("Unsafe agent name");
989
+ if (draft.name !== name)
990
+ throw new Error("Settings overrides cannot rename the agent; fork it instead");
991
+ const base = this.getBase(name) ?? (() => {
992
+ try {
993
+ return this.get(name);
994
+ } catch {
995
+ return undefined;
996
+ }
997
+ })();
998
+ if (!base) throw new Error(`Settings override has no base agent named ${JSON.stringify(name)}`);
999
+ if (base.name !== name)
1000
+ throw new Error(`Settings override has no base agent named ${JSON.stringify(name)}`);
1001
+ const override = diffAgentSettings(base, { ...draft, name });
1002
+ const content = serializeAgentSettings(override);
1003
+ parseAgentSettings(content, undefined, name);
1004
+ const filePath = this.destination(name, scope, original, "override");
1005
+ this.assertWritableDestination(filePath, scope);
1006
+ this.writeContent(filePath, name, content);
1007
+ this.reload();
1008
+ return this.get(name);
1009
+ }
1010
+
1011
+ /** Remove a user/project customization so the base definition shows through. */
1012
+ removeCustomization(name: string, scope: AgentScope): void {
1013
+ const current = this.types.get(name);
1014
+ const customization = current?.customization;
1015
+ if (!customization || customization.scope !== scope)
1016
+ throw new Error(`No ${scope} customization found for ${JSON.stringify(name)}`);
1017
+ const resolved = resolve(customization.filePath);
1018
+ if (
1019
+ dirname(resolved) !== this.preferredScopeDirectory(scope) ||
1020
+ (!resolved.endsWith(".md") &&
1021
+ !AGENT_OVERRIDE_EXTENSIONS.some((extension) => resolved.endsWith(extension)))
1022
+ )
1023
+ throw new Error(`Unsafe agent destination: ${resolved}`);
1024
+ const stat = lstatIfPresent(resolved);
1025
+ if (!stat?.isFile()) throw new Error(`Unsafe agent destination: ${resolved}`);
1026
+ unlinkSync(resolved);
1027
+ this.reload();
1028
+ }
1029
+ }