pi-squire 0.13.1 → 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/CHANGELOG.md +141 -1
- package/README.md +192 -87
- package/package.json +3 -3
- package/src/agents.ts +361 -111
- package/src/child.ts +72 -21
- package/src/index.ts +243 -67
- package/src/navigator.ts +28 -52
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +10 -33
- package/src/result-sessions.ts +39 -0
- package/src/results.ts +2 -0
- package/src/resume-checks.ts +45 -12
- package/src/resume.ts +7 -2
- package/src/tmux.ts +4 -88
- package/src/tracking.ts +1 -5
- package/src/popups.ts +0 -49
package/src/agents.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
// The parent's agent roster:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
-
/**
|
|
30
|
-
const
|
|
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
|
|
36
|
-
|
|
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
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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:
|
|
113
|
+
({ frontmatter, body } = parseFrontmatter(text) as { frontmatter: unknown; body: string });
|
|
73
114
|
} catch (err) {
|
|
74
|
-
return {
|
|
115
|
+
return { invalid: `${path} is not readable frontmatter (${(err as Error).message})`, notes: [] };
|
|
75
116
|
}
|
|
76
|
-
if (frontmatter === null || typeof frontmatter !== "object")
|
|
77
|
-
return {
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 {
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
|
|
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
|
|
113
|
-
if (
|
|
114
|
-
notes.push(`${path} has fallback ${JSON.stringify(
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
137
|
-
|
|
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
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
154
|
-
return { tools
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
337
|
+
entries = readdirSync(dir);
|
|
193
338
|
} catch {
|
|
194
|
-
return
|
|
339
|
+
return files; // no agents folder
|
|
195
340
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
+
disable(name, path, `${path} is not named as a usable agent (letters, digits, _ or -)`);
|
|
208
380
|
continue;
|
|
209
381
|
}
|
|
210
|
-
|
|
211
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|