pi-squire 0.14.0 → 0.16.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 (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. package/src/workspace.ts +38 -90
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -54,8 +54,9 @@
54
54
  "@earendil-works/pi-ai": "1.3.0",
55
55
  "@earendil-works/pi-coding-agent": "1.3.0",
56
56
  "@earendil-works/pi-tui": "1.3.0",
57
- "@types/node": "26.6.3",
58
- "typebox": "1.3.27",
57
+ "@types/node": "26.6.5",
58
+ "pi-session-mail": "0.6.0",
59
+ "typebox": "1.4.3",
59
60
  "typescript": "7.0.2"
60
61
  }
61
62
  }
package/src/agents.ts CHANGED
@@ -1,18 +1,27 @@
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";
12
- import { homedir } from "node:os";
13
- import { dirname, isAbsolute, join, resolve } from "node:path";
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), `auto_exit` (default true),
15
+ // `prompt_mode` (`append`, the default, or `replace`), `enabled` (false leaves
16
+ // the agent out) and `locked` (true, or a list of `model` and `thinking`). Any
17
+ // other key is ignored, left for whatever else reads the file. The body is the
18
+ // delegate's instructions. A file that cannot be read as an agent is disabled:
19
+ // left out, with a warning, and a call naming it is refused with the reason.
20
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
14
22
 
15
23
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
24
+ import { toolMatches } from "./tool-ceiling.ts";
16
25
 
17
26
  /** The levels `pi --thinking` accepts. */
18
27
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -23,142 +32,281 @@ export function isThinking(value: string): value is Thinking {
23
32
  return (THINKING_LEVELS as readonly string[]).includes(value);
24
33
  }
25
34
 
35
+ /** The fields an agent file can lock against a call's override. */
36
+ export const LOCKABLE = ["model", "thinking"] as const;
37
+
38
+ export type Lockable = (typeof LOCKABLE)[number];
39
+
40
+ /** How the body joins the delegate's prompt: wrapped in `<agent_instructions>`, or as it is. */
41
+ export type PromptMode = "append" | "replace";
42
+
26
43
  /** An agent name is one safe path segment and one safe tmux window name. */
27
44
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
28
45
 
29
- /** `provider/id`; the id may itself contain slashes. */
30
- const MODEL = /^[^\s/]+\/\S+$/;
46
+ /** A display name names a tmux window and a Pi session: ASCII letters, digits, `-` and `_` only. */
47
+ const DISPLAY_NAME = /^[A-Za-z0-9_-]+$/;
31
48
 
32
49
  export type Agent = {
33
50
  name: string;
51
+ /** Shown instead of the name on the delegate card, `/delegates` and the window title; from `display_name`, `[A-Za-z0-9_-]+`. */
52
+ displayName?: string;
34
53
  description: string;
35
- model: string;
36
- thinking: Thinking;
37
- /** Whether the delegate closes itself after a normal completion; `auto-exit`, default true. */
54
+ /** Passed to the child's `--model` unchanged; undefined means the parent's model. */
55
+ model?: string;
56
+ /** Undefined means the parent's level. */
57
+ thinking?: Thinking;
58
+ /** Whether the delegate closes itself after a normal completion; `auto_exit`, default true. */
38
59
  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[];
43
- /** Absolute paths of extra extensions the delegate loads, from `extensions`; empty when absent. */
44
- extensions: string[];
60
+ /** Current grant: true for the roster, or an explicit list of child identities. */
61
+ allowNested?: true | string[];
62
+ /** Presence of a permission map only; not a policy evaluation or privilege comparison. */
63
+ hasPermission?: boolean;
64
+ /**
65
+ * The delegate's complete tool list, from a `tools` line of tool names:
66
+ * undefined when the field is absent or holds only `-name` entries (the
67
+ * parent's tools), empty when it is empty (no tools).
68
+ */
69
+ tools?: string[];
70
+ /** The names or `*` patterns a `tools` line of only `-name` entries takes off the parent's tools; empty otherwise. */
71
+ toolsExcept: 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)
126
+ return { invalid: `${path} has auto_exit ${JSON.stringify(fields.auto_exit)}, not true or false`, notes: [] };
127
+ const nested = fields.allow_nested_delegation === false ? undefined : fields.allow_nested_delegation;
128
+ if (!validNested(nested))
105
129
  return {
106
- warning: `${path} has exclude-tools ${JSON.stringify(unknownTool)}, not a tool this session has; skipped`,
130
+ invalid: `${path} has allow_nested_delegation that is not true, false or a list of agent names`,
131
+ notes: [],
107
132
  };
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.
133
+ const launch = toolsField(path, fields);
134
+ if ("invalid" in launch) return { ...launch, notes: [] };
135
+ const model = field(fields, "model");
136
+ const fallback = toolList(fields.fallback);
137
+ const pattern = patternProblem(path, model, fallback) ?? commaProblem(path, fields.fallback);
138
+ if (pattern !== undefined) return { invalid: pattern, notes: [] };
139
+ const shown = displayName(path, fields);
140
+ if ("invalid" in shown) return { ...shown, notes: [] };
141
+ // A field that cannot be used is dropped with a note; it never costs the agent.
111
142
  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
- });
143
+ const thinking = thinkingField(path, fields, notes);
144
+ warnDelegationTools(path, nested, launch.tools, notes);
145
+ if (fallback === null)
146
+ notes.push(`${path} has fallback ${JSON.stringify(fields.fallback)}, not a list of models; ignored`);
147
+ if (fields["exclude-tools"] !== undefined)
148
+ notes.push(`${path} has exclude-tools, which is no longer read; list the delegate's tools in tools instead`);
120
149
  return {
121
150
  notes,
122
151
  agent: {
123
152
  name,
124
- description: description as string,
125
- model: model as string,
126
- thinking: thinking as Thinking,
153
+ ...shown,
154
+ description: field(fields, "description") ?? name,
155
+ ...(model === undefined ? {} : { model }),
156
+ ...(thinking === undefined ? {} : { thinking }),
127
157
  autoExit: autoExit ?? true,
128
- excludeTools,
158
+ ...(nested === undefined ? {} : { allowNested: nested === true ? true : [...new Set(nested as string[])] }),
159
+ hasPermission:
160
+ fields.permission !== null && typeof fields.permission === "object" && !Array.isArray(fields.permission),
129
161
  ...launch,
130
- fallback,
131
- prompt: body,
162
+ fallback: fallback ?? [],
163
+ locked: lockedFields(path, fields, { model, thinking }, notes),
164
+ promptMode: promptMode(path, fields, notes),
165
+ prompt: body.trim(),
166
+ path,
132
167
  },
133
168
  };
134
169
  }
135
170
 
136
- /** The `tools` and `extensions` fields, or the warning that leaves the agent out. */
137
- function launchFields(
171
+ function warnDelegationTools(path: string, nested: unknown, tools: string[] | undefined, notes: string[]): void {
172
+ if (nested === true || (Array.isArray(nested) && nested.length > 0)) return;
173
+ if (
174
+ tools?.some((name) =>
175
+ ["delegate", "delegation_status", "delegation_close", "delegation_resume"].some((tool) =>
176
+ toolMatches(name, tool),
177
+ ),
178
+ )
179
+ )
180
+ notes.push(
181
+ `${path} lists delegation tools without allow_nested_delegation; listing tools alone does not grant nesting`,
182
+ );
183
+ }
184
+
185
+ function validNested(value: unknown): boolean {
186
+ return (
187
+ value === undefined ||
188
+ value === true ||
189
+ (Array.isArray(value) && value.every((entry) => typeof entry === "string" && NAME.test(entry)))
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Why a `model` or `fallback` entry makes the file invalid: a `*` pattern.
195
+ * Each entry names one model (an exact `provider/id` or an alias Pi resolves
196
+ * to one model), at launch through Pi's `--model` and in the delegate's own
197
+ * switch after a usage limit.
198
+ */
199
+ function patternProblem(
200
+ path: string,
201
+ model: string | undefined,
202
+ fallback: string[] | null | undefined,
203
+ ): string | undefined {
204
+ if (model?.includes("*"))
205
+ return `${path} has model ${JSON.stringify(model)}: * patterns are not accepted; name one model, as provider/id or an alias`;
206
+ const entry = (fallback ?? []).find((name) => name.includes("*"));
207
+ if (entry !== undefined)
208
+ return `${path} has fallback ${JSON.stringify(entry)}: * patterns are not accepted; name one model per entry, as provider/id or an alias`;
209
+ return undefined;
210
+ }
211
+
212
+ /** Why a `fallback` list entry holding a comma makes the file invalid: each entry names one model. */
213
+ function commaProblem(path: string, fallback: unknown): string | undefined {
214
+ const entry = commaEntry(fallback);
215
+ return entry === undefined
216
+ ? undefined
217
+ : `${path} has fallback entry ${JSON.stringify(entry)}, which holds a comma; name one model per entry, or write fallback as one comma-separated line`;
218
+ }
219
+
220
+ /** The `thinking` field; an unknown level is dropped with a note, so the parent's level applies. */
221
+ function thinkingField(path: string, fields: Record<string, unknown>, notes: string[]): Thinking | undefined {
222
+ if (fields.thinking === undefined || fields.thinking === null) return undefined;
223
+ const value = typeof fields.thinking === "string" ? fields.thinking.trim() : undefined;
224
+ if (value !== undefined && isThinking(value)) return value;
225
+ notes.push(
226
+ `${path} has thinking ${JSON.stringify(fields.thinking)}, not one of ${THINKING_LEVELS.join(", ")}; the parent's level applies`,
227
+ );
228
+ return undefined;
229
+ }
230
+
231
+ /** The `display_name` field, as a spread, or why the file is invalid: anything but `[A-Za-z0-9_-]+`, empty included. */
232
+ function displayName(path: string, fields: Record<string, unknown>): { displayName?: string } | { invalid: string } {
233
+ if (!Object.hasOwn(fields, "display_name")) return {};
234
+ const value = fields.display_name;
235
+ if (typeof value === "string" && DISPLAY_NAME.test(value)) return { displayName: value };
236
+ return {
237
+ invalid: `${path} has display_name ${JSON.stringify(value)}, which may hold only ASCII letters, digits, - and _`,
238
+ };
239
+ }
240
+
241
+ /** The `prompt_mode` field; an unknown mode is ignored with a note, so the default applies. */
242
+ function promptMode(path: string, fields: Record<string, unknown>, notes: string[]): PromptMode {
243
+ const value = fields.prompt_mode;
244
+ if (value === undefined || value === null || value === "append") return "append";
245
+ if (value === "replace") return "replace";
246
+ notes.push(`${path} has prompt_mode ${JSON.stringify(value)}, not append or replace; append applies`);
247
+ return "append";
248
+ }
249
+
250
+ /** The `locked` field: `true` locks the lockable fields the file sets; a list names them. */
251
+ function lockedFields(
252
+ path: string,
253
+ fields: Record<string, unknown>,
254
+ set: { model?: string; thinking?: string },
255
+ notes: string[],
256
+ ): Lockable[] {
257
+ const value = fields.locked;
258
+ if (value === undefined || value === null || value === false) return [];
259
+ if (value === true) return LOCKABLE.filter((key) => set[key] !== undefined);
260
+ const names = toolList(value);
261
+ if (names === null || names === undefined) {
262
+ notes.push(`${path} has locked ${JSON.stringify(value)}, not true or a list of model and thinking; ignored`);
263
+ return [];
264
+ }
265
+ const locked: Lockable[] = [];
266
+ for (const entry of names) {
267
+ if ((LOCKABLE as readonly string[]).includes(entry)) {
268
+ if (!locked.includes(entry as Lockable)) locked.push(entry as Lockable);
269
+ } else notes.push(`${path} has locked ${JSON.stringify(entry)}, not model or thinking; dropped`);
270
+ }
271
+ return locked;
272
+ }
273
+
274
+ /**
275
+ * The `tools` field: tool names (`*` patterns allowed) are the whole list;
276
+ * only `-name` entries take those off the parent's tools. A `+name` entry,
277
+ * names and `-name` entries mixed, or a YAML list entry holding a comma make
278
+ * the file invalid.
279
+ */
280
+ function toolsField(
138
281
  path: string,
139
282
  frontmatter: Record<string, unknown>,
140
- ): { tools: string[]; extensions: string[] } | { warning: string } {
141
- const tools = toolList(frontmatter.tools);
283
+ ): { tools?: string[]; toolsExcept: string[] } | { invalid: string } {
284
+ // `tools:` with no value is YAML null: present and empty, unlike an absent field.
285
+ const tools = Object.hasOwn(frontmatter, "tools") && frontmatter.tools === null ? [] : toolList(frontmatter.tools);
142
286
  if (tools === null)
143
287
  return {
144
- warning: `${path} has tools ${JSON.stringify(frontmatter.tools)}, not a comma-separated list of tool names; skipped`,
288
+ invalid: `${path} has tools ${JSON.stringify(frontmatter.tools)}, not a comma-separated list of tool names`,
145
289
  };
146
- const paths = toolList(frontmatter.extensions);
147
- if (paths === null)
290
+ if (tools === undefined) return { toolsExcept: [] };
291
+ const comma = commaEntry(frontmatter.tools);
292
+ if (comma !== undefined)
148
293
  return {
149
- warning: `${path} has extensions ${JSON.stringify(frontmatter.extensions)}, not a comma-separated list of paths; skipped`,
294
+ 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`,
150
295
  };
151
- const extensions = (paths ?? []).map((entry) => resolveExtension(entry, dirname(path)));
152
- 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 };
155
- }
156
-
157
- /** An extension path as an absolute one: `~` is the home directory, a relative path is against the AGENT.md's directory. */
158
- function resolveExtension(entry: string, dir: string): string {
159
- if (entry === "~") return homedir();
160
- if (entry.startsWith("~/")) return join(homedir(), entry.slice(2));
161
- return isAbsolute(entry) ? entry : resolve(dir, entry);
296
+ const plus = tools.find((name) => name.startsWith("+"));
297
+ if (plus !== undefined)
298
+ return {
299
+ 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`,
300
+ };
301
+ const minus = tools.filter((name) => name.startsWith("-"));
302
+ if (minus.length === 0) return { tools, toolsExcept: [] };
303
+ if (minus.length < tools.length)
304
+ return {
305
+ 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`,
306
+ };
307
+ const bare = minus.find((name) => name.slice(1).trim() === "");
308
+ if (bare !== undefined) return { invalid: `${path} has tools entry ${JSON.stringify(bare)}, which names no tool` };
309
+ return { toolsExcept: minus.map((name) => name.slice(1).trim()) };
162
310
  }
163
311
 
164
312
  /** A list of names (tools or models), comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
@@ -169,6 +317,16 @@ function toolList(value: unknown): string[] | undefined | null {
169
317
  return names.map((name) => name.trim()).filter((name) => name !== "");
170
318
  }
171
319
 
320
+ /**
321
+ * The first entry of a YAML list `value` that holds a comma, such as
322
+ * `["read,-bash"]`: one entry must name one thing. Undefined for a list
323
+ * without one, and for a comma-separated line, which `toolList` splits.
324
+ */
325
+ function commaEntry(value: unknown): string | undefined {
326
+ if (!Array.isArray(value)) return undefined;
327
+ return value.find((entry): entry is string => typeof entry === "string" && entry.includes(","));
328
+ }
329
+
172
330
  /** A true/false frontmatter value; undefined when absent, null when it is neither. */
173
331
  function flag(value: unknown): boolean | undefined | null {
174
332
  if (value === undefined || value === null) return undefined;
@@ -178,39 +336,152 @@ function flag(value: unknown): boolean | undefined | null {
178
336
  return null;
179
337
  }
180
338
 
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[];
339
+ /** The `<name>.md` files directly in `dir`, by name; empty when there is no such folder. */
340
+ function agentFiles(dir: string): Map<string, string> {
341
+ const files = new Map<string, string>();
342
+ let entries: string[];
191
343
  try {
192
- names = readdirSync(dir).sort();
344
+ entries = readdirSync(dir);
193
345
  } catch {
194
- return { agents: [], warnings: [] }; // no agents directory yet
346
+ return files; // no agents folder
195
347
  }
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;
348
+ for (const entry of entries.sort()) {
349
+ if (!entry.endsWith(".md")) continue;
350
+ const path = join(dir, entry);
201
351
  try {
202
- text = readFileSync(path, "utf8");
352
+ if (!statSync(path).isFile()) continue;
203
353
  } catch {
204
354
  continue;
205
355
  }
356
+ files.set(entry.slice(0, -".md".length), path);
357
+ }
358
+ return files;
359
+ }
360
+
361
+ /**
362
+ * Read every agent, in name order: the global folder, then `projectDir` when
363
+ * given, whose files replace global ones of the same name. The caller passes
364
+ * `projectDir` only for a trusted project. A file that cannot be used is
365
+ * disabled: left out, warned about and kept in `disabled` with why; a file
366
+ * with `enabled: false` is left out quietly.
367
+ */
368
+ export function readRoster(agentDir: string, projectDir?: string): Roster {
369
+ const files = agentFiles(globalAgentsDir(agentDir));
370
+ if (projectDir !== undefined) for (const [name, path] of agentFiles(projectDir)) files.set(name, path);
371
+ const agents: Agent[] = [];
372
+ const disabled: Disabled[] = [];
373
+ const warnings: string[] = [];
374
+ const retired = join(agentDir, "squire", "agents");
375
+ if (existsSync(retired))
376
+ warnings.push(
377
+ `${retired} is no longer read; move each <name>/AGENT.md there to ${join(globalAgentsDir(agentDir), "<name>.md")}`,
378
+ );
379
+ const disable = (name: string, path: string, reason: string): void => {
380
+ disabled.push({ name, path, reason });
381
+ warnings.push(`${reason}; skipped`);
382
+ };
383
+ for (const name of [...files.keys()].sort()) {
384
+ const path = files.get(name) as string;
206
385
  if (!NAME.test(name)) {
207
- warnings.push(`${path} is not under a usable agent name; skipped`);
386
+ disable(name, path, `${path} is not named as a usable agent (letters, digits, _ or -)`);
387
+ continue;
388
+ }
389
+ let text: string;
390
+ try {
391
+ text = readFileSync(path, "utf8");
392
+ } catch (err) {
393
+ disable(name, path, `${path} could not be read (${(err as Error).message})`);
208
394
  continue;
209
395
  }
210
- const { agent, warning, notes } = parseAgent(name, path, text, tools);
211
- warnings.push(...(notes ?? []));
396
+ const { agent, invalid, notes } = parseAgent(name, path, text);
397
+ warnings.push(...notes);
212
398
  if (agent !== undefined) agents.push(agent);
213
- else if (warning !== undefined) warnings.push(warning);
399
+ else if (invalid !== undefined) disable(name, path, invalid);
400
+ }
401
+ for (const agent of agents) {
402
+ const targets = agent.allowNested === true ? agents.map((item) => item.name) : (agent.allowNested ?? []);
403
+ for (const name of targets) {
404
+ const child = agents.find((item) => item.name === name);
405
+ if (!child)
406
+ warnings.push(
407
+ `${agent.path} allows unknown or unavailable nested agent ${JSON.stringify(name)}; it cannot launch until present in the current roster`,
408
+ );
409
+ else if (agent.hasPermission && !child.hasPermission)
410
+ warnings.push(
411
+ `${agent.path} grants nested agent ${JSON.stringify(name)} (${child.path}) without a permission block; this is a delegation grant, not proof of equal or lesser permission authority`,
412
+ );
413
+ }
414
+ }
415
+ return { agents, disabled, warnings };
416
+ }
417
+
418
+ /** The refusal for a call that names a disabled agent; undefined when `name` is not one. */
419
+ export function disabledProblem(roster: Roster, name: string): string | undefined {
420
+ const found = roster.disabled.find((item) => item.name === name);
421
+ return found === undefined
422
+ ? undefined
423
+ : `agent ${JSON.stringify(name)} is disabled because its file is invalid: ${found.reason}`;
424
+ }
425
+
426
+ /**
427
+ * A call's `model` and `thinking` with every field `agent` locks taken out,
428
+ * so the call runs as if it had passed none of them, and the notice for those
429
+ * it passed with a value other than `locked`, the value the lock keeps (the
430
+ * file's, or the parent's when the file sets none). A locked field passed with
431
+ * that value is no override: it is dropped quietly.
432
+ */
433
+ export function withoutLocked<T extends { model?: string; thinking?: string }>(
434
+ agent: Agent,
435
+ request: T,
436
+ locked: { model?: string; thinking?: string },
437
+ ): { request: T; notice?: string } {
438
+ const passed = agent.locked.filter((key) => request[key] !== undefined);
439
+ if (passed.length === 0) return { request };
440
+ const kept = { ...request };
441
+ for (const key of passed) delete kept[key];
442
+ const refused = passed.filter((key) => request[key] !== locked[key]);
443
+ if (refused.length === 0) return { request: kept };
444
+ const given = refused.map((key) => `${key} ${request[key]}`).join(" and ");
445
+ return {
446
+ request: kept,
447
+ notice: `Agent ${agent.name} locks ${refused.join(" and ")}, so the ${given} given ${refused.length > 1 ? "were" : "was"} not used.`,
448
+ };
449
+ }
450
+
451
+ /**
452
+ * The `delegateTools` setting in `<agent dir>/squire/settings.json`: tool
453
+ * names or `*` patterns added to every delegate's tools, whether they come
454
+ * from the agent's `tools` or the parent. Empty when unset or when the file
455
+ * cannot be read or parsed (the worktree settings report that when a launch
456
+ * needs them); a value that is not a list of names, or an entry holding a
457
+ * comma or starting with `+` or `-`, refuses the launch with the file named.
458
+ */
459
+ export function delegateTools(agentDir: string): string[] {
460
+ const file = join(agentDir, "squire", "settings.json");
461
+ let parsed: unknown;
462
+ try {
463
+ parsed = JSON.parse(readFileSync(file, "utf8"));
464
+ } catch {
465
+ return [];
466
+ }
467
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
468
+ const value = (parsed as Record<string, unknown>).delegateTools;
469
+ if (value === undefined) return [];
470
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim() !== "")) {
471
+ throw new Error(
472
+ `invalid delegate tools settings ${file}: delegateTools must be a list of tool names or patterns, not ${JSON.stringify(value)}`,
473
+ );
214
474
  }
215
- return { agents, warnings };
475
+ const names = value.map((entry: string) => entry.trim());
476
+ const comma = names.find((name) => name.includes(","));
477
+ if (comma !== undefined)
478
+ throw new Error(
479
+ `invalid delegate tools settings ${file}: delegateTools entry ${JSON.stringify(comma)} holds a comma; give one tool name or pattern per entry`,
480
+ );
481
+ const modifier = names.find((name) => name.startsWith("+") || name.startsWith("-"));
482
+ if (modifier !== undefined)
483
+ throw new Error(
484
+ `invalid delegate tools settings ${file}: delegateTools entries are tool names; ${JSON.stringify(modifier)} starts with ${modifier[0]}, which is not accepted there`,
485
+ );
486
+ return names;
216
487
  }