@ferris1225/pi-subagents 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -1,155 +1,168 @@
1
- /**
2
- * Configuration load/save for pi-subagents.
3
- *
4
- * Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
5
- * and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
6
- * to defaults instead of throwing, so a hand-edited or partially-written file can
7
- * never break the extension at runtime.
8
- */
9
-
10
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
- import { dirname, join } from "node:path";
12
- import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
13
-
14
- /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
15
- export const BUILTIN_AGENT_NAMES = ["explore", "plan", "worker", "reviewer"] as const;
16
- export type BuiltinAgentName = (typeof BUILTIN_AGENT_NAMES)[number];
17
-
18
- /** Agents enabled out of the box. `plan` ships but is opt-in: a worker plans internally. */
19
- export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explore", "worker", "reviewer"];
20
-
21
- export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
22
- export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
23
-
24
- export const CONFIG_FILE_NAME = "pi-subagents.json";
25
-
26
- export interface SubagentsConfig {
27
- /** Agent names that are discoverable and injected. Default: explore, worker, reviewer. */
28
- enabledAgents: string[];
29
- /** Per-agent model override, keyed by agent name, as "provider/model-id". */
30
- agentModels: Record<string, string>;
31
- /** Whether to inject the delegation directive into the parent system prompt. Default: true. */
32
- proactiveInjection: boolean;
33
- /** Which agent directories to discover from. Default: "user". */
34
- agentScope: AgentScope;
35
- }
36
-
37
- export const DEFAULT_CONFIG: SubagentsConfig = {
38
- enabledAgents: [...DEFAULT_ENABLED_AGENTS],
39
- agentModels: {},
40
- proactiveInjection: true,
41
- agentScope: "user",
42
- };
43
-
44
- export function getConfigPath(agentDir: string = getAgentDir()): string {
45
- return join(agentDir, CONFIG_FILE_NAME);
46
- }
47
-
48
- function isRecord(value: unknown): value is Record<string, unknown> {
49
- return typeof value === "object" && value !== null && !Array.isArray(value);
50
- }
51
-
52
- function isAgentScope(value: unknown): value is AgentScope {
53
- return typeof value === "string" && (AGENT_SCOPE_VALUES as readonly string[]).includes(value);
54
- }
55
-
56
- function isModelReference(value: unknown): value is string {
57
- if (typeof value !== "string") return false;
58
- const normalized = value.trim();
59
- const slash = normalized.indexOf("/");
60
- return slash > 0 && slash < normalized.length - 1 && !/\s/u.test(normalized);
61
- }
62
-
63
- /**
64
- * Merge a raw parsed JSON value over the defaults, dropping invalid fields.
65
- * Exported for tests.
66
- */
67
- export function normalizeConfig(raw: unknown): SubagentsConfig {
68
- if (!isRecord(raw)) return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
69
-
70
- const config: SubagentsConfig = {
71
- enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
72
- agentModels: {},
73
- proactiveInjection: DEFAULT_CONFIG.proactiveInjection,
74
- agentScope: DEFAULT_CONFIG.agentScope,
75
- };
76
-
77
- if (Array.isArray(raw.enabledAgents)) {
78
- const names = raw.enabledAgents.filter(
79
- (name): name is string => typeof name === "string" && name.trim().length > 0,
80
- );
81
- // An explicitly empty array is honored (disables all agents); otherwise keep valid names.
82
- config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
83
- }
84
-
85
- if (isRecord(raw.agentModels)) {
86
- for (const [key, value] of Object.entries(raw.agentModels)) {
87
- if (isModelReference(value)) config.agentModels[key.trim()] = value.trim();
88
- }
89
- }
90
-
91
- if (typeof raw.proactiveInjection === "boolean") {
92
- config.proactiveInjection = raw.proactiveInjection;
93
- }
94
-
95
- if (isAgentScope(raw.agentScope)) {
96
- config.agentScope = raw.agentScope;
97
- }
98
-
99
- return config;
100
- }
101
-
102
- /**
103
- * Load config. A missing file is a normal state and yields the defaults (not an error).
104
- * A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
105
- */
106
- export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
107
- let text: string;
108
- try {
109
- text = await readFile(configPath, "utf8");
110
- } catch (error) {
111
- if (isNodeError(error) && error.code === "ENOENT") {
112
- return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
113
- }
114
- // Unreadable for another reason: fall back to defaults but do not crash startup.
115
- return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
116
- }
117
-
118
- let parsed: unknown;
119
- try {
120
- parsed = JSON.parse(text);
121
- } catch {
122
- return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
123
- }
124
-
125
- return normalizeConfig(parsed);
126
- }
127
-
128
- /**
129
- * Save config atomically (temp file + rename) serialized through pi's per-file
130
- * mutation queue so concurrent writers cannot interleave.
131
- */
132
- export async function saveConfig(
133
- config: SubagentsConfig,
134
- configPath: string = getConfigPath(),
135
- ): Promise<void> {
136
- const normalized = normalizeConfig(config);
137
- await mkdir(dirname(configPath), { recursive: true });
138
- await withFileMutationQueue(configPath, async () => {
139
- const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
140
- try {
141
- await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
142
- await rename(temporaryPath, configPath);
143
- } finally {
144
- await rm(temporaryPath, { force: true }).catch(() => undefined);
145
- }
146
- });
147
- }
148
-
149
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
150
- return error instanceof Error;
151
- }
152
-
153
- export function errorMessage(error: unknown): string {
154
- return error instanceof Error ? error.message : String(error);
155
- }
1
+ /**
2
+ * Configuration load/save for pi-subagents.
3
+ *
4
+ * Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
5
+ * and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
6
+ * to defaults instead of throwing, so a hand-edited or partially-written file can
7
+ * never break the extension at runtime.
8
+ */
9
+
10
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
13
+
14
+ /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
15
+ export const BUILTIN_AGENT_NAMES = ["explore", "plan", "worker", "reviewer"] as const;
16
+ export type BuiltinAgentName = (typeof BUILTIN_AGENT_NAMES)[number];
17
+
18
+ /** Agents enabled out of the box. `plan` ships but is opt-in: a worker plans internally. */
19
+ export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explore", "worker", "reviewer"];
20
+
21
+ export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
22
+ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
23
+
24
+ /** Thinking levels accepted by pi's `--thinking` option. */
25
+ export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
26
+ export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
27
+ export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "max";
28
+
29
+ export const CONFIG_FILE_NAME = "pi-subagents.json";
30
+
31
+ export interface SubagentsConfig {
32
+ /** Agent names that are discoverable and injected. Default: explore, worker, reviewer. */
33
+ enabledAgents: string[];
34
+ /** Per-agent model override, keyed by agent name, as "provider/model-id". */
35
+ agentModels: Record<string, string>;
36
+ /** Thinking level passed to every sub-agent. Default: "max". */
37
+ thinkingLevel: ThinkingLevel;
38
+ /** Whether to inject the delegation directive into the parent system prompt. Default: true. */
39
+ proactiveInjection: boolean;
40
+ /** Which agent directories to discover from. Default: "user". */
41
+ agentScope: AgentScope;
42
+ }
43
+
44
+ export const DEFAULT_CONFIG: SubagentsConfig = {
45
+ enabledAgents: [...DEFAULT_ENABLED_AGENTS],
46
+ agentModels: {},
47
+ thinkingLevel: DEFAULT_THINKING_LEVEL,
48
+ proactiveInjection: true,
49
+ agentScope: "user",
50
+ };
51
+
52
+ export function getConfigPath(agentDir: string = getAgentDir()): string {
53
+ return join(agentDir, CONFIG_FILE_NAME);
54
+ }
55
+
56
+ function isRecord(value: unknown): value is Record<string, unknown> {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+
60
+ function isAgentScope(value: unknown): value is AgentScope {
61
+ return typeof value === "string" && (AGENT_SCOPE_VALUES as readonly string[]).includes(value);
62
+ }
63
+
64
+ function isModelReference(value: unknown): value is string {
65
+ if (typeof value !== "string") return false;
66
+ const normalized = value.trim();
67
+ const slash = normalized.indexOf("/");
68
+ return slash > 0 && slash < normalized.length - 1 && !/\s/u.test(normalized);
69
+ }
70
+
71
+ /**
72
+ * Merge a raw parsed JSON value over the defaults, dropping invalid fields.
73
+ * Exported for tests.
74
+ */
75
+ export function normalizeConfig(raw: unknown): SubagentsConfig {
76
+ if (!isRecord(raw)) return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
77
+
78
+ const config: SubagentsConfig = {
79
+ enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
80
+ agentModels: {},
81
+ thinkingLevel: DEFAULT_CONFIG.thinkingLevel,
82
+ proactiveInjection: DEFAULT_CONFIG.proactiveInjection,
83
+ agentScope: DEFAULT_CONFIG.agentScope,
84
+ };
85
+
86
+ if (Array.isArray(raw.enabledAgents)) {
87
+ const names = raw.enabledAgents.filter(
88
+ (name): name is string => typeof name === "string" && name.trim().length > 0,
89
+ );
90
+ // An explicitly empty array is honored (disables all agents); otherwise keep valid names.
91
+ config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
92
+ }
93
+
94
+ if (isRecord(raw.agentModels)) {
95
+ for (const [key, value] of Object.entries(raw.agentModels)) {
96
+ if (isModelReference(value)) config.agentModels[key.trim()] = value.trim();
97
+ }
98
+ }
99
+
100
+ if (typeof raw.thinkingLevel === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(raw.thinkingLevel)) {
101
+ config.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
102
+ }
103
+
104
+ if (typeof raw.proactiveInjection === "boolean") {
105
+ config.proactiveInjection = raw.proactiveInjection;
106
+ }
107
+
108
+ if (isAgentScope(raw.agentScope)) {
109
+ config.agentScope = raw.agentScope;
110
+ }
111
+
112
+ return config;
113
+ }
114
+
115
+ /**
116
+ * Load config. A missing file is a normal state and yields the defaults (not an error).
117
+ * A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
118
+ */
119
+ export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
120
+ let text: string;
121
+ try {
122
+ text = await readFile(configPath, "utf8");
123
+ } catch (error) {
124
+ if (isNodeError(error) && error.code === "ENOENT") {
125
+ return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
126
+ }
127
+ // Unreadable for another reason: fall back to defaults but do not crash startup.
128
+ return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
129
+ }
130
+
131
+ let parsed: unknown;
132
+ try {
133
+ parsed = JSON.parse(text);
134
+ } catch {
135
+ return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
136
+ }
137
+
138
+ return normalizeConfig(parsed);
139
+ }
140
+
141
+ /**
142
+ * Save config atomically (temp file + rename) serialized through pi's per-file
143
+ * mutation queue so concurrent writers cannot interleave.
144
+ */
145
+ export async function saveConfig(
146
+ config: SubagentsConfig,
147
+ configPath: string = getConfigPath(),
148
+ ): Promise<void> {
149
+ const normalized = normalizeConfig(config);
150
+ await mkdir(dirname(configPath), { recursive: true });
151
+ await withFileMutationQueue(configPath, async () => {
152
+ const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
153
+ try {
154
+ await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
155
+ await rename(temporaryPath, configPath);
156
+ } finally {
157
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
158
+ }
159
+ });
160
+ }
161
+
162
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
163
+ return error instanceof Error;
164
+ }
165
+
166
+ export function errorMessage(error: unknown): string {
167
+ return error instanceof Error ? error.message : String(error);
168
+ }