pi-squire 0.3.1 → 0.3.2

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 CHANGED
@@ -6,6 +6,27 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.2] - 2026-10-09
10
+
11
+ ### Added
12
+
13
+ - Agent frontmatter `extensions` lists extension paths loaded only by that
14
+ agent's delegate, each passed to its Pi as `-e` on top of its normal
15
+ extensions. `~` expands and relative paths resolve against the `AGENT.md`'s
16
+ directory; a path that does not exist leaves the agent out with a warning.
17
+ - Agent frontmatter `tools` lists the delegate's visible tools, passed as
18
+ `--tools`. Without discovery tools such as `codemode` or `tool_search`, only
19
+ listed tools are visible; Pi can keep unlisted MCP tools registered unless
20
+ the list includes an `mcp__` entry, and discovery tools can expose them.
21
+ Names are not checked against the parent's tools, and `exclude-tools` still
22
+ applies alongside.
23
+
24
+ ### Fixed
25
+
26
+ - The `delegate` tool treats `null` or the string `"null"` in `model`,
27
+ `thinking` or `label` as unset, so the agent's defaults apply. Before, a
28
+ `null` model crashed the call and `"null"` was refused as not provider/id.
29
+
9
30
  ## [0.3.1] - 2026-10-03
10
31
 
11
32
  ### Changed
@@ -148,7 +169,8 @@ author's dotfiles; the changes below are against that copy.
148
169
 
149
170
  - The published file list includes `child.ts`, which `index.ts` imports.
150
171
 
151
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.3.1...HEAD
172
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.3.2...HEAD
173
+ [0.3.2]: https://github.com/gvanderclay/pi-squire/compare/v0.3.1...v0.3.2
152
174
  [0.3.1]: https://github.com/gvanderclay/pi-squire/compare/v0.3.0...v0.3.1
153
175
  [0.3.0]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...v0.3.0
154
176
  [0.2.2]: https://github.com/gvanderclay/pi-squire/compare/v0.2.1...v0.2.2
package/README.md CHANGED
@@ -116,7 +116,7 @@ The roster is read at call time from `<agent dir>/agents/<name>/AGENT.md`, one
116
116
  directory per agent, so a new agent is usable without a reload. Frontmatter
117
117
  carries `description`, `model` (`provider/id`) and `thinking`, and optionally
118
118
  `auto-exit` (`true` or `false`, default `true`; see [Auto-exit](#auto-exit)),
119
- `exclude-tools` and `fallback`. The body is the delegate's system prompt,
119
+ `exclude-tools`, `tools`, `extensions` and `fallback`. The body is the delegate's system prompt,
120
120
  appended to Pi's own prompt; it reaches the child as a path to a private file
121
121
  under the system temporary folder, so the text stays off the command line. The
122
122
  [first-use example](#first-use) is a complete `AGENT.md`.
@@ -133,6 +133,23 @@ delegate's result reaches the parent through the `message:*` provider's reply
133
133
  when its run settles, not through a tool, so excluding tools cannot stop it
134
134
  answering.
135
135
 
136
+ `tools` lists tools for the delegate as a comma-separated or YAML list
137
+ (`tools: device_run`). It reaches the child as `--tools device_run`, which
138
+ covers extension tools too. Without discovery tools such as `codemode` or
139
+ `tool_search`, only the listed tools are visible. Pi can keep unlisted MCP
140
+ tools registered unless the list includes an `mcp__` entry; discovery tools can
141
+ also expose them. Names are not checked against the parent's tools, since a
142
+ tool from one of the agent's own `extensions` exists only in the child. Empty or
143
+ absent passes no flag. `exclude-tools` still applies alongside.
144
+
145
+ `extensions` lists extension paths only this agent's delegate loads, as a
146
+ comma-separated or YAML list. Each reaches the child as a repeated
147
+ `-e <absolute path>`, on top of its normal extensions, so pi-squire and the
148
+ `message:*` provider still load and the result still reaches the parent. `~`
149
+ and `~/` expand to the home directory and a relative path resolves against the
150
+ directory holding that `AGENT.md`. A path that does not exist when the roster
151
+ is read leaves the agent out, with a warning.
152
+
136
153
  `fallback` lists models to try, in order, when the agent's `model` is
137
154
  usage-limited (see [Usage limits](#usage-limits)), as a comma-separated list
138
155
  or a YAML list of `provider/id`. An entry that is not `provider/id` is dropped
@@ -150,7 +167,8 @@ An `AGENT.md` that cannot be used is left out of the roster and named in a
150
167
  warning. That covers missing fields, a model that is not `provider/id`, an
151
168
  unknown thinking level, an `auto-exit` that is not `true` or `false`, an
152
169
  `exclude-tools` that is not a list of names or names a tool the session does
153
- not have, and an empty body. With no agents at all, `/delegate` says how to
170
+ not have, a `tools` or `extensions` that is not a list, an extension path that
171
+ does not exist, and an empty body. With no agents at all, `/delegate` says how to
154
172
  add one.
155
173
 
156
174
  ## Configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
package/src/agents.ts CHANGED
@@ -2,10 +2,15 @@
2
2
  // call time so a new agent is usable without a reload. Frontmatter carries
3
3
  // `description`, `model` and `thinking`, and optionally `auto-exit` (default
4
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); the body
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
6
10
  // is the delegate's system prompt. A file that is missing or malformed is left out, with a warning.
7
- import { readdirSync, readFileSync } from "node:fs";
8
- import { join } from "node:path";
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, isAbsolute, join, resolve } from "node:path";
9
14
 
10
15
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
11
16
 
@@ -33,6 +38,10 @@ export type Agent = {
33
38
  autoExit: boolean;
34
39
  /** Tools the delegate goes without, from `exclude-tools`; empty means all of them. */
35
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[];
36
45
  /** Models to try, in order, when `model` is usage-limited; from `fallback`, empty when absent. */
37
46
  fallback: string[];
38
47
  /** The body of `AGENT.md`: the delegate's system prompt. */
@@ -96,6 +105,8 @@ function parseAgent(
96
105
  return {
97
106
  warning: `${path} has exclude-tools ${JSON.stringify(unknownTool)}, not a tool this session has; skipped`,
98
107
  };
108
+ const launch = launchFields(path, frontmatter);
109
+ if ("warning" in launch) return launch;
99
110
  // A bad fallback entry is dropped with a note; it never costs the agent.
100
111
  const notes: string[] = [];
101
112
  const listedFallback = toolList(frontmatter.fallback);
@@ -115,12 +126,41 @@ function parseAgent(
115
126
  thinking: thinking as Thinking,
116
127
  autoExit: autoExit ?? true,
117
128
  excludeTools,
129
+ ...launch,
118
130
  fallback,
119
131
  prompt: body,
120
132
  },
121
133
  };
122
134
  }
123
135
 
136
+ /** The `tools` and `extensions` fields, or the warning that leaves the agent out. */
137
+ function launchFields(
138
+ path: string,
139
+ frontmatter: Record<string, unknown>,
140
+ ): { tools: string[]; extensions: string[] } | { warning: string } {
141
+ const tools = toolList(frontmatter.tools);
142
+ if (tools === null)
143
+ return {
144
+ warning: `${path} has tools ${JSON.stringify(frontmatter.tools)}, not a comma-separated list of tool names; skipped`,
145
+ };
146
+ const paths = toolList(frontmatter.extensions);
147
+ if (paths === null)
148
+ return {
149
+ warning: `${path} has extensions ${JSON.stringify(frontmatter.extensions)}, not a comma-separated list of paths; skipped`,
150
+ };
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);
162
+ }
163
+
124
164
  /** A list of names (tools or models), comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
125
165
  function toolList(value: unknown): string[] | undefined | null {
126
166
  if (value === undefined || value === null) return undefined;
package/src/index.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  // call) the delegate closes its own window after a normal completion; the
18
18
  // delegate's side lives in `child.ts`. With it off the window stays open.
19
19
  // An agent's `exclude-tools` goes to the child as `--exclude-tools`; a name the
20
- // session has no tool for leaves the agent out of the roster.
20
+ // session has no tool for leaves the agent out of the roster. Its `tools` goes
21
+ // as `--tools` and each of its `extensions` as `-e`, both for the child alone.
21
22
  //
22
23
  // The model gets three tools. `delegate` takes agent, task and optional model
23
24
  // and thinking, validates them the way the command does, and starts the
@@ -308,7 +309,7 @@ function toolDescription(agents: readonly Agent[]): string {
308
309
  "agents",
309
310
  "<name>",
310
311
  "AGENT.md",
311
- )} with frontmatter description, model and thinking and the prompt as its body; /delegate explains the format too.`;
312
+ )} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback) and the prompt as its body; /delegate explains the format too.`;
312
313
  }
313
314
  return [
314
315
  intro,
@@ -318,7 +319,7 @@ function toolDescription(agents: readonly Agent[]): string {
318
319
  (agent) =>
319
320
  `- ${agent.name} — ${agent.description} (default ${[agent.model, ...agent.fallback.map((model) => `fallback ${model}`)].join(", ")}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
320
321
  agent.excludeTools.length > 0 ? `, no ${agent.excludeTools.join("/")}` : ""
321
- })`,
322
+ }${agent.tools.length > 0 ? `, tools: ${agent.tools.join("/")}` : ""})`,
322
323
  ),
323
324
  "",
324
325
  "`model` and `thinking` override the agent's defaults for this call; an unknown value is refused with close matches. `label` names the delegate's window and session; give one that says what the task is. `delegation_status` reports a delegation, and `delegation_close` ends one.",
@@ -387,7 +388,7 @@ export default function delegate(
387
388
  "agents",
388
389
  "<name>",
389
390
  "AGENT.md",
390
- )} with frontmatter description, model and thinking, and the prompt as its body`,
391
+ )} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback), and the prompt as its body`,
391
392
  );
392
393
  }
393
394
  const agent = roster.agents.find((candidate) => candidate.name === request.agent);
@@ -447,6 +448,8 @@ export default function delegate(
447
448
  start.model,
448
449
  "--thinking",
449
450
  start.thinking,
451
+ ...start.agent.extensions.flatMap((extension) => ["-e", extension]),
452
+ ...(start.agent.tools.length > 0 ? ["--tools", start.agent.tools.join(",")] : []),
450
453
  ...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
451
454
  "--append-system-prompt",
452
455
  prompt,
@@ -536,18 +539,27 @@ export default function delegate(
536
539
  }),
537
540
  async execute(
538
541
  _toolCallId: string,
539
- params: { agent: string; task: string; model?: string; thinking?: string; label?: string; auto_exit?: boolean },
542
+ params: {
543
+ agent: string;
544
+ task: string;
545
+ model?: string | null;
546
+ thinking?: string | null;
547
+ label?: string | null;
548
+ auto_exit?: boolean | null;
549
+ },
540
550
  _signal: unknown,
541
551
  _onUpdate: unknown,
542
552
  ctx: ExtensionContext,
543
553
  ) {
544
554
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
555
+ // Some models fill every optional field with null or the string "null"; both mean unset.
556
+ const given = (value: string | null | undefined) => (value == null || value === "null" ? undefined : value);
545
557
  const start = await validate(
546
558
  {
547
559
  agent: params.agent,
548
- model: params.model,
549
- thinking: params.thinking,
550
- label: params.label,
560
+ model: given(params.model),
561
+ thinking: given(params.thinking),
562
+ label: given(params.label),
551
563
  autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
552
564
  task: params.task ?? "",
553
565
  },