pi-squire 0.14.0 → 0.15.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/agents.ts CHANGED
@@ -1,14 +1,25 @@
1
- // The parent's agent roster: `<agent dir>/squire/agents/<name>/AGENT.md`, read at
2
- // call time so a new agent is usable without a reload. Frontmatter carries
3
- // `description`, `model` and `thinking`, and optionally `auto-exit` (default
4
- // true) and `exclude-tools` (tools the delegate goes without, passed to its Pi
5
- // as `--exclude-tools`; each must be a tool the parent session has), `tools`
6
- // (the delegate's complete tool allowlist, passed as `--tools`, not checked
7
- // against the parent) and `extensions` (extra extension paths for the
8
- // delegate only, each passed as `-e`; `~` expands, relative paths resolve
9
- // against the AGENT.md's directory, and a missing path leaves the agent out); the body
10
- // is the delegate's system prompt. A file that is missing or malformed is left out, with a warning.
11
- import { existsSync, readdirSync, readFileSync } from "node:fs";
1
+ // The parent's agent roster: one file per agent, `<name>.md`, in the agents
2
+ // folders Pi agent tooling shares. `<agent dir>/agents/` holds the global
3
+ // agents; `<cwd>/.pi/agents/` holds a project's, read only when the project is
4
+ // trusted, and a project file replaces the global file of the same name. The
5
+ // roster is read at call time, so a new agent is usable without a reload.
6
+ //
7
+ // Every frontmatter field is optional: `description` (default the name),
8
+ // `display_name` (ASCII letters, digits, `-` and `_` only; anything else,
9
+ // empty included, makes the file invalid), `model` and `fallback` (each one model, `provider/id` or an
10
+ // alias, passed to the child's Pi unchanged; a `*` pattern, or a list entry
11
+ // holding a comma, makes the file invalid; absent `model` means the parent's),
12
+ // `thinking` (absent or unknown means the parent's level), `tools` (the
13
+ // delegate's complete tool list; absent means the parent's tools, empty means
14
+ // none; a list entry holding a comma makes the file invalid), `extensions`
15
+ // (extra extension paths for the delegate only; `~` expands, relative paths resolve against the agent file's folder, and a
16
+ // missing path leaves the agent out), `auto-exit` (default true),
17
+ // `prompt_mode` (`append`, the default, or `replace`), `enabled` (false leaves
18
+ // the agent out) and `locked` (true, or a list of `model` and `thinking`). Any
19
+ // other key is ignored, left for whatever else reads the file. The body is the
20
+ // delegate's instructions. A file that cannot be read as an agent is disabled:
21
+ // left out, with a warning, and a call naming it is refused with the reason.
22
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
23
  import { homedir } from "node:os";
13
24
  import { dirname, isAbsolute, join, resolve } from "node:path";
14
25
 
@@ -23,138 +34,268 @@ export function isThinking(value: string): value is Thinking {
23
34
  return (THINKING_LEVELS as readonly string[]).includes(value);
24
35
  }
25
36
 
37
+ /** The fields an agent file can lock against a call's override. */
38
+ export const LOCKABLE = ["model", "thinking"] as const;
39
+
40
+ export type Lockable = (typeof LOCKABLE)[number];
41
+
42
+ /** How the body joins the delegate's prompt: wrapped in `<agent_instructions>`, or as it is. */
43
+ export type PromptMode = "append" | "replace";
44
+
26
45
  /** An agent name is one safe path segment and one safe tmux window name. */
27
46
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
28
47
 
29
- /** `provider/id`; the id may itself contain slashes. */
30
- const MODEL = /^[^\s/]+\/\S+$/;
48
+ /** A display name names a tmux window and a Pi session: ASCII letters, digits, `-` and `_` only. */
49
+ const DISPLAY_NAME = /^[A-Za-z0-9_-]+$/;
31
50
 
32
51
  export type Agent = {
33
52
  name: string;
53
+ /** Shown instead of the name on the delegate card, `/delegates` and the window title; from `display_name`, `[A-Za-z0-9_-]+`. */
54
+ displayName?: string;
34
55
  description: string;
35
- model: string;
36
- thinking: Thinking;
56
+ /** Passed to the child's `--model` unchanged; undefined means the parent's model. */
57
+ model?: string;
58
+ /** Undefined means the parent's level. */
59
+ thinking?: Thinking;
37
60
  /** Whether the delegate closes itself after a normal completion; `auto-exit`, default true. */
38
61
  autoExit: boolean;
39
- /** Tools the delegate goes without, from `exclude-tools`; empty means all of them. */
40
- excludeTools: string[];
41
- /** The delegate's complete tool allowlist, from `tools`; empty means no `--tools` flag. */
42
- tools: string[];
62
+ /**
63
+ * The delegate's complete tool list, from a `tools` line of tool names:
64
+ * undefined when the field is absent or holds only `-name` entries (the
65
+ * parent's tools), empty when it is empty (no tools).
66
+ */
67
+ tools?: string[];
68
+ /** The names or `*` patterns a `tools` line of only `-name` entries takes off the parent's tools; empty otherwise. */
69
+ toolsExcept: string[];
43
70
  /** Absolute paths of extra extensions the delegate loads, from `extensions`; empty when absent. */
44
71
  extensions: string[];
45
72
  /** Models to try, in order, when `model` is usage-limited; from `fallback`, empty when absent. */
46
73
  fallback: string[];
47
- /** The body of `AGENT.md`: the delegate's system prompt. */
74
+ /** The fields a call cannot override, from `locked`. */
75
+ locked: Lockable[];
76
+ promptMode: PromptMode;
77
+ /** The body of the agent file: the delegate's instructions; may be empty. */
48
78
  prompt: string;
79
+ /** The file the agent was read from. */
80
+ path: string;
49
81
  };
50
82
 
83
+ /** An agent whose file is invalid: left out of the roster, and refused by name with `reason`. */
84
+ export type Disabled = { name: string; path: string; reason: string };
85
+
51
86
  export type Roster = {
52
87
  agents: Agent[];
53
- /** One line per `AGENT.md` left out, naming its path and the reason. */
88
+ /** The agents whose files are invalid, by name; a call naming one is refused with its reason. */
89
+ disabled: Disabled[];
90
+ /** One line per agent file left out or field ignored, naming its path and the reason. */
54
91
  warnings: string[];
55
92
  };
56
93
 
94
+ /** The global agents folder, `<agent dir>/agents`. */
95
+ export const globalAgentsDir = (agentDir: string): string => join(agentDir, "agents");
96
+
97
+ /** A project's agents folder, `<cwd>/.pi/agents`. */
98
+ export const projectAgentsDir = (cwd: string): string => join(cwd, ".pi", "agents");
99
+
57
100
  const field = (frontmatter: Record<string, unknown>, key: string): string | undefined => {
58
101
  const value = frontmatter[key];
59
102
  return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
60
103
  };
61
104
 
62
- /** Parse one `AGENT.md`; undefined with a reason when it cannot be an agent. */
63
- function parseAgent(
64
- name: string,
65
- path: string,
66
- text: string,
67
- tools: readonly string[] | undefined,
68
- ): { agent?: Agent; warning?: string; notes?: string[] } {
69
- let frontmatter: Record<string, unknown>;
105
+ /** One parsed file: the agent, or why it is invalid; neither for a file with `enabled: false`. */
106
+ type Parsed = { agent?: Agent; invalid?: string; notes: string[] };
107
+
108
+ /** Parse one agent file; no agent, with why it is invalid when it is, when it cannot be an agent or is disabled. */
109
+ function parseAgent(name: string, path: string, text: string): Parsed {
110
+ let frontmatter: unknown;
70
111
  let body: string;
71
112
  try {
72
- ({ frontmatter, body } = parseFrontmatter(text) as { frontmatter: Record<string, unknown>; body: string });
113
+ ({ frontmatter, body } = parseFrontmatter(text) as { frontmatter: unknown; body: string });
73
114
  } catch (err) {
74
- return { warning: `${path} is not readable frontmatter (${(err as Error).message}); skipped` };
115
+ return { invalid: `${path} is not readable frontmatter (${(err as Error).message})`, notes: [] };
75
116
  }
76
- if (frontmatter === null || typeof frontmatter !== "object")
77
- return { warning: `${path} has no frontmatter fields; skipped` };
78
- const description = field(frontmatter, "description");
79
- const model = field(frontmatter, "model");
80
- const thinking = field(frontmatter, "thinking");
81
- const missing = [
82
- description === undefined ? "description" : undefined,
83
- model === undefined ? "model" : undefined,
84
- thinking === undefined ? "thinking" : undefined,
85
- ].filter((key): key is string => key !== undefined);
86
- if (missing.length > 0) return { warning: `${path} has no ${missing.join(", ")}; skipped` };
87
- if (!MODEL.test(model as string))
88
- return { warning: `${path} has model ${JSON.stringify(model)}, not provider/id; skipped` };
89
- if (!isThinking(thinking as string))
90
- return {
91
- warning: `${path} has thinking ${JSON.stringify(thinking)}, not one of ${THINKING_LEVELS.join(", ")}; skipped`,
92
- };
93
- if (body === "") return { warning: `${path} has no prompt body; skipped` };
94
- const autoExit = flag(frontmatter["auto-exit"]);
117
+ if (frontmatter === null || typeof frontmatter !== "object" || Array.isArray(frontmatter))
118
+ return { invalid: `${path} has frontmatter that is not a set of fields`, notes: [] };
119
+ const fields = frontmatter as Record<string, unknown>;
120
+ const enabled = flag(fields.enabled);
121
+ if (enabled === null)
122
+ return { invalid: `${path} has enabled ${JSON.stringify(fields.enabled)}, not true or false`, notes: [] };
123
+ if (enabled === false) return { notes: [] };
124
+ const autoExit = flag(fields["auto-exit"]);
95
125
  if (autoExit === null)
96
- return { warning: `${path} has auto-exit ${JSON.stringify(frontmatter["auto-exit"])}, not true or false; skipped` };
97
- const listed = toolList(frontmatter["exclude-tools"]);
98
- if (listed === null)
99
- return {
100
- warning: `${path} has exclude-tools ${JSON.stringify(frontmatter["exclude-tools"])}, not a comma-separated list of tool names; skipped`,
101
- };
102
- const excludeTools = listed ?? [];
103
- const unknownTool = tools === undefined ? undefined : excludeTools.find((tool) => !tools.includes(tool));
104
- if (unknownTool !== undefined)
105
- return {
106
- warning: `${path} has exclude-tools ${JSON.stringify(unknownTool)}, not a tool this session has; skipped`,
107
- };
108
- const launch = launchFields(path, frontmatter);
109
- if ("warning" in launch) return launch;
110
- // A bad fallback entry is dropped with a note; it never costs the agent.
126
+ return { invalid: `${path} has auto-exit ${JSON.stringify(fields["auto-exit"])}, not true or false`, notes: [] };
127
+ const launch = launchFields(path, fields);
128
+ if ("invalid" in launch) return { ...launch, notes: [] };
129
+ const model = field(fields, "model");
130
+ const fallback = toolList(fields.fallback);
131
+ const pattern = patternProblem(path, model, fallback) ?? commaProblem(path, fields.fallback);
132
+ if (pattern !== undefined) return { invalid: pattern, notes: [] };
133
+ const shown = displayName(path, fields);
134
+ if ("invalid" in shown) return { ...shown, notes: [] };
135
+ // A field that cannot be used is dropped with a note; it never costs the agent.
111
136
  const notes: string[] = [];
112
- const listedFallback = toolList(frontmatter.fallback);
113
- if (listedFallback === null)
114
- notes.push(`${path} has fallback ${JSON.stringify(frontmatter.fallback)}, not a list of provider/id; ignored`);
115
- const fallback = (listedFallback ?? []).filter((entry) => {
116
- if (MODEL.test(entry)) return true;
117
- notes.push(`${path} has fallback ${JSON.stringify(entry)}, not provider/id; dropped`);
118
- return false;
119
- });
137
+ const thinking = thinkingField(path, fields, notes);
138
+ if (fallback === null)
139
+ notes.push(`${path} has fallback ${JSON.stringify(fields.fallback)}, not a list of models; ignored`);
140
+ if (fields["exclude-tools"] !== undefined)
141
+ notes.push(`${path} has exclude-tools, which is no longer read; list the delegate's tools in tools instead`);
120
142
  return {
121
143
  notes,
122
144
  agent: {
123
145
  name,
124
- description: description as string,
125
- model: model as string,
126
- thinking: thinking as Thinking,
146
+ ...shown,
147
+ description: field(fields, "description") ?? name,
148
+ ...(model === undefined ? {} : { model }),
149
+ ...(thinking === undefined ? {} : { thinking }),
127
150
  autoExit: autoExit ?? true,
128
- excludeTools,
129
151
  ...launch,
130
- fallback,
131
- prompt: body,
152
+ fallback: fallback ?? [],
153
+ locked: lockedFields(path, fields, { model, thinking }, notes),
154
+ promptMode: promptMode(path, fields, notes),
155
+ prompt: body.trim(),
156
+ path,
132
157
  },
133
158
  };
134
159
  }
135
160
 
136
- /** The `tools` and `extensions` fields, or the warning that leaves the agent out. */
137
- function launchFields(
161
+ /**
162
+ * Why a `model` or `fallback` entry makes the file invalid: a `*` pattern.
163
+ * Each entry names one model (an exact `provider/id` or an alias Pi resolves
164
+ * to one model), at launch through Pi's `--model` and in the delegate's own
165
+ * switch after a usage limit.
166
+ */
167
+ function patternProblem(
168
+ path: string,
169
+ model: string | undefined,
170
+ fallback: string[] | null | undefined,
171
+ ): string | undefined {
172
+ if (model?.includes("*"))
173
+ return `${path} has model ${JSON.stringify(model)}: * patterns are not accepted; name one model, as provider/id or an alias`;
174
+ const entry = (fallback ?? []).find((name) => name.includes("*"));
175
+ if (entry !== undefined)
176
+ return `${path} has fallback ${JSON.stringify(entry)}: * patterns are not accepted; name one model per entry, as provider/id or an alias`;
177
+ return undefined;
178
+ }
179
+
180
+ /** Why a `fallback` list entry holding a comma makes the file invalid: each entry names one model. */
181
+ function commaProblem(path: string, fallback: unknown): string | undefined {
182
+ const entry = commaEntry(fallback);
183
+ return entry === undefined
184
+ ? undefined
185
+ : `${path} has fallback entry ${JSON.stringify(entry)}, which holds a comma; name one model per entry, or write fallback as one comma-separated line`;
186
+ }
187
+
188
+ /** The `thinking` field; an unknown level is dropped with a note, so the parent's level applies. */
189
+ function thinkingField(path: string, fields: Record<string, unknown>, notes: string[]): Thinking | undefined {
190
+ if (fields.thinking === undefined || fields.thinking === null) return undefined;
191
+ const value = typeof fields.thinking === "string" ? fields.thinking.trim() : undefined;
192
+ if (value !== undefined && isThinking(value)) return value;
193
+ notes.push(
194
+ `${path} has thinking ${JSON.stringify(fields.thinking)}, not one of ${THINKING_LEVELS.join(", ")}; the parent's level applies`,
195
+ );
196
+ return undefined;
197
+ }
198
+
199
+ /** The `display_name` field, as a spread, or why the file is invalid: anything but `[A-Za-z0-9_-]+`, empty included. */
200
+ function displayName(path: string, fields: Record<string, unknown>): { displayName?: string } | { invalid: string } {
201
+ if (!Object.hasOwn(fields, "display_name")) return {};
202
+ const value = fields.display_name;
203
+ if (typeof value === "string" && DISPLAY_NAME.test(value)) return { displayName: value };
204
+ return {
205
+ invalid: `${path} has display_name ${JSON.stringify(value)}, which may hold only ASCII letters, digits, - and _`,
206
+ };
207
+ }
208
+
209
+ /** The `prompt_mode` field; an unknown mode is ignored with a note, so the default applies. */
210
+ function promptMode(path: string, fields: Record<string, unknown>, notes: string[]): PromptMode {
211
+ const value = fields.prompt_mode;
212
+ if (value === undefined || value === null || value === "append") return "append";
213
+ if (value === "replace") return "replace";
214
+ notes.push(`${path} has prompt_mode ${JSON.stringify(value)}, not append or replace; append applies`);
215
+ return "append";
216
+ }
217
+
218
+ /** The `locked` field: `true` locks the lockable fields the file sets; a list names them. */
219
+ function lockedFields(
220
+ path: string,
221
+ fields: Record<string, unknown>,
222
+ set: { model?: string; thinking?: string },
223
+ notes: string[],
224
+ ): Lockable[] {
225
+ const value = fields.locked;
226
+ if (value === undefined || value === null || value === false) return [];
227
+ if (value === true) return LOCKABLE.filter((key) => set[key] !== undefined);
228
+ const names = toolList(value);
229
+ if (names === null || names === undefined) {
230
+ notes.push(`${path} has locked ${JSON.stringify(value)}, not true or a list of model and thinking; ignored`);
231
+ return [];
232
+ }
233
+ const locked: Lockable[] = [];
234
+ for (const entry of names) {
235
+ if ((LOCKABLE as readonly string[]).includes(entry)) {
236
+ if (!locked.includes(entry as Lockable)) locked.push(entry as Lockable);
237
+ } else notes.push(`${path} has locked ${JSON.stringify(entry)}, not model or thinking; dropped`);
238
+ }
239
+ return locked;
240
+ }
241
+
242
+ /**
243
+ * The `tools` field: tool names (`*` patterns allowed) are the whole list;
244
+ * only `-name` entries take those off the parent's tools. A `+name` entry,
245
+ * names and `-name` entries mixed, or a YAML list entry holding a comma make
246
+ * the file invalid.
247
+ */
248
+ function toolsField(
138
249
  path: string,
139
250
  frontmatter: Record<string, unknown>,
140
- ): { tools: string[]; extensions: string[] } | { warning: string } {
141
- const tools = toolList(frontmatter.tools);
251
+ ): { tools?: string[]; toolsExcept: string[] } | { invalid: string } {
252
+ // `tools:` with no value is YAML null: present and empty, unlike an absent field.
253
+ const tools = Object.hasOwn(frontmatter, "tools") && frontmatter.tools === null ? [] : toolList(frontmatter.tools);
142
254
  if (tools === null)
143
255
  return {
144
- warning: `${path} has tools ${JSON.stringify(frontmatter.tools)}, not a comma-separated list of tool names; skipped`,
256
+ invalid: `${path} has tools ${JSON.stringify(frontmatter.tools)}, not a comma-separated list of tool names`,
257
+ };
258
+ if (tools === undefined) return { toolsExcept: [] };
259
+ const comma = commaEntry(frontmatter.tools);
260
+ if (comma !== undefined)
261
+ return {
262
+ invalid: `${path} has tools entry ${JSON.stringify(comma)}, which holds a comma; give one tool per entry, or write tools as one comma-separated line`,
145
263
  };
264
+ const plus = tools.find((name) => name.startsWith("+"));
265
+ if (plus !== undefined)
266
+ return {
267
+ invalid: `${path} has tools entry ${JSON.stringify(plus)}: +name entries are not accepted; list every tool by name, or only -name entries to take tools off the parent's`,
268
+ };
269
+ const minus = tools.filter((name) => name.startsWith("-"));
270
+ if (minus.length === 0) return { tools, toolsExcept: [] };
271
+ if (minus.length < tools.length)
272
+ return {
273
+ invalid: `${path} has tools that mix tool names with -name entries; list every tool by name, or only -name entries to take tools off the parent's`,
274
+ };
275
+ const bare = minus.find((name) => name.slice(1).trim() === "");
276
+ if (bare !== undefined) return { invalid: `${path} has tools entry ${JSON.stringify(bare)}, which names no tool` };
277
+ return { toolsExcept: minus.map((name) => name.slice(1).trim()) };
278
+ }
279
+
280
+ /** The `tools` and `extensions` fields, or why the agent is invalid. */
281
+ function launchFields(
282
+ path: string,
283
+ frontmatter: Record<string, unknown>,
284
+ ): { tools?: string[]; toolsExcept: string[]; extensions: string[] } | { invalid: string } {
285
+ const tools = toolsField(path, frontmatter);
286
+ if ("invalid" in tools) return tools;
146
287
  const paths = toolList(frontmatter.extensions);
147
288
  if (paths === null)
148
289
  return {
149
- warning: `${path} has extensions ${JSON.stringify(frontmatter.extensions)}, not a comma-separated list of paths; skipped`,
290
+ invalid: `${path} has extensions ${JSON.stringify(frontmatter.extensions)}, not a comma-separated list of paths`,
150
291
  };
151
292
  const extensions = (paths ?? []).map((entry) => resolveExtension(entry, dirname(path)));
152
293
  const absent = extensions.find((entry) => !existsSync(entry));
153
- if (absent !== undefined) return { warning: `${path} has extension ${absent}, which does not exist; skipped` };
154
- return { tools: tools ?? [], extensions };
294
+ if (absent !== undefined) return { invalid: `${path} has extension ${absent}, which does not exist` };
295
+ return { ...tools, extensions };
155
296
  }
156
297
 
157
- /** An extension path as an absolute one: `~` is the home directory, a relative path is against the AGENT.md's directory. */
298
+ /** An extension path as an absolute one: `~` is the home directory, a relative path is against the agent file's folder. */
158
299
  function resolveExtension(entry: string, dir: string): string {
159
300
  if (entry === "~") return homedir();
160
301
  if (entry.startsWith("~/")) return join(homedir(), entry.slice(2));
@@ -169,6 +310,16 @@ function toolList(value: unknown): string[] | undefined | null {
169
310
  return names.map((name) => name.trim()).filter((name) => name !== "");
170
311
  }
171
312
 
313
+ /**
314
+ * The first entry of a YAML list `value` that holds a comma, such as
315
+ * `["read,-bash"]`: one entry must name one thing. Undefined for a list
316
+ * without one, and for a comma-separated line, which `toolList` splits.
317
+ */
318
+ function commaEntry(value: unknown): string | undefined {
319
+ if (!Array.isArray(value)) return undefined;
320
+ return value.find((entry): entry is string => typeof entry === "string" && entry.includes(","));
321
+ }
322
+
172
323
  /** A true/false frontmatter value; undefined when absent, null when it is neither. */
173
324
  function flag(value: unknown): boolean | undefined | null {
174
325
  if (value === undefined || value === null) return undefined;
@@ -178,39 +329,138 @@ function flag(value: unknown): boolean | undefined | null {
178
329
  return null;
179
330
  }
180
331
 
181
- /**
182
- * Read every agent under `<agentDir>/squire/agents/`, in name order. A directory
183
- * with no `AGENT.md` is not an agent and is passed over quietly; an
184
- * `AGENT.md` that cannot be used is warned about and left out. `tools` are
185
- * the session's tool names, which `exclude-tools` is checked against;
186
- * undefined skips that check, as before the session's tools are known.
187
- */
188
- export function readRoster(agentDir: string, tools?: readonly string[]): Roster {
189
- const dir = join(agentDir, "squire", "agents");
190
- let names: string[];
332
+ /** The `<name>.md` files directly in `dir`, by name; empty when there is no such folder. */
333
+ function agentFiles(dir: string): Map<string, string> {
334
+ const files = new Map<string, string>();
335
+ let entries: string[];
191
336
  try {
192
- names = readdirSync(dir).sort();
337
+ entries = readdirSync(dir);
193
338
  } catch {
194
- return { agents: [], warnings: [] }; // no agents directory yet
339
+ return files; // no agents folder
195
340
  }
196
- const agents: Agent[] = [];
197
- const warnings: string[] = [];
198
- for (const name of names) {
199
- const path = join(dir, name, "AGENT.md");
200
- let text: string;
341
+ for (const entry of entries.sort()) {
342
+ if (!entry.endsWith(".md")) continue;
343
+ const path = join(dir, entry);
201
344
  try {
202
- text = readFileSync(path, "utf8");
345
+ if (!statSync(path).isFile()) continue;
203
346
  } catch {
204
347
  continue;
205
348
  }
349
+ files.set(entry.slice(0, -".md".length), path);
350
+ }
351
+ return files;
352
+ }
353
+
354
+ /**
355
+ * Read every agent, in name order: the global folder, then `projectDir` when
356
+ * given, whose files replace global ones of the same name. The caller passes
357
+ * `projectDir` only for a trusted project. A file that cannot be used is
358
+ * disabled: left out, warned about and kept in `disabled` with why; a file
359
+ * with `enabled: false` is left out quietly.
360
+ */
361
+ export function readRoster(agentDir: string, projectDir?: string): Roster {
362
+ const files = agentFiles(globalAgentsDir(agentDir));
363
+ if (projectDir !== undefined) for (const [name, path] of agentFiles(projectDir)) files.set(name, path);
364
+ const agents: Agent[] = [];
365
+ const disabled: Disabled[] = [];
366
+ const warnings: string[] = [];
367
+ const retired = join(agentDir, "squire", "agents");
368
+ if (existsSync(retired))
369
+ warnings.push(
370
+ `${retired} is no longer read; move each <name>/AGENT.md there to ${join(globalAgentsDir(agentDir), "<name>.md")}`,
371
+ );
372
+ const disable = (name: string, path: string, reason: string): void => {
373
+ disabled.push({ name, path, reason });
374
+ warnings.push(`${reason}; skipped`);
375
+ };
376
+ for (const name of [...files.keys()].sort()) {
377
+ const path = files.get(name) as string;
206
378
  if (!NAME.test(name)) {
207
- warnings.push(`${path} is not under a usable agent name; skipped`);
379
+ disable(name, path, `${path} is not named as a usable agent (letters, digits, _ or -)`);
208
380
  continue;
209
381
  }
210
- const { agent, warning, notes } = parseAgent(name, path, text, tools);
211
- warnings.push(...(notes ?? []));
382
+ let text: string;
383
+ try {
384
+ text = readFileSync(path, "utf8");
385
+ } catch (err) {
386
+ disable(name, path, `${path} could not be read (${(err as Error).message})`);
387
+ continue;
388
+ }
389
+ const { agent, invalid, notes } = parseAgent(name, path, text);
390
+ warnings.push(...notes);
212
391
  if (agent !== undefined) agents.push(agent);
213
- else if (warning !== undefined) warnings.push(warning);
392
+ else if (invalid !== undefined) disable(name, path, invalid);
393
+ }
394
+ return { agents, disabled, warnings };
395
+ }
396
+
397
+ /** The refusal for a call that names a disabled agent; undefined when `name` is not one. */
398
+ export function disabledProblem(roster: Roster, name: string): string | undefined {
399
+ const found = roster.disabled.find((item) => item.name === name);
400
+ return found === undefined
401
+ ? undefined
402
+ : `agent ${JSON.stringify(name)} is disabled because its file is invalid: ${found.reason}`;
403
+ }
404
+
405
+ /**
406
+ * A call's `model` and `thinking` with every field `agent` locks taken out,
407
+ * so the call runs as if it had passed none of them, and the notice for those
408
+ * it passed with a value other than `locked`, the value the lock keeps (the
409
+ * file's, or the parent's when the file sets none). A locked field passed with
410
+ * that value is no override: it is dropped quietly.
411
+ */
412
+ export function withoutLocked<T extends { model?: string; thinking?: string }>(
413
+ agent: Agent,
414
+ request: T,
415
+ locked: { model?: string; thinking?: string },
416
+ ): { request: T; notice?: string } {
417
+ const passed = agent.locked.filter((key) => request[key] !== undefined);
418
+ if (passed.length === 0) return { request };
419
+ const kept = { ...request };
420
+ for (const key of passed) delete kept[key];
421
+ const refused = passed.filter((key) => request[key] !== locked[key]);
422
+ if (refused.length === 0) return { request: kept };
423
+ const given = refused.map((key) => `${key} ${request[key]}`).join(" and ");
424
+ return {
425
+ request: kept,
426
+ notice: `Agent ${agent.name} locks ${refused.join(" and ")}, so the ${given} given ${refused.length > 1 ? "were" : "was"} not used.`,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * The `delegateTools` setting in `<agent dir>/squire/settings.json`: tool
432
+ * names or `*` patterns added to every delegate's tools, whether they come
433
+ * from the agent's `tools` or the parent. Empty when unset or when the file
434
+ * cannot be read or parsed (the worktree settings report that when a launch
435
+ * needs them); a value that is not a list of names, or an entry holding a
436
+ * comma or starting with `+` or `-`, refuses the launch with the file named.
437
+ */
438
+ export function delegateTools(agentDir: string): string[] {
439
+ const file = join(agentDir, "squire", "settings.json");
440
+ let parsed: unknown;
441
+ try {
442
+ parsed = JSON.parse(readFileSync(file, "utf8"));
443
+ } catch {
444
+ return [];
445
+ }
446
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
447
+ const value = (parsed as Record<string, unknown>).delegateTools;
448
+ if (value === undefined) return [];
449
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim() !== "")) {
450
+ throw new Error(
451
+ `invalid delegate tools settings ${file}: delegateTools must be a list of tool names or patterns, not ${JSON.stringify(value)}`,
452
+ );
214
453
  }
215
- return { agents, warnings };
454
+ const names = value.map((entry: string) => entry.trim());
455
+ const comma = names.find((name) => name.includes(","));
456
+ if (comma !== undefined)
457
+ throw new Error(
458
+ `invalid delegate tools settings ${file}: delegateTools entry ${JSON.stringify(comma)} holds a comma; give one tool name or pattern per entry`,
459
+ );
460
+ const modifier = names.find((name) => name.startsWith("+") || name.startsWith("-"));
461
+ if (modifier !== undefined)
462
+ throw new Error(
463
+ `invalid delegate tools settings ${file}: delegateTools entries are tool names; ${JSON.stringify(modifier)} starts with ${modifier[0]}, which is not accepted there`,
464
+ );
465
+ return names;
216
466
  }