pi-squire 0.16.0 → 0.17.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 CHANGED
@@ -6,6 +6,25 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.17.0] - 2026-11-01
10
+
11
+ ### Breaking
12
+
13
+ - Nested launches and resumes no longer cap a child's tools to its direct
14
+ parent's. A nested child gets the tools its own agent file gives, as at top
15
+ level, and `session:launch` additions are no longer cut back afterwards; the
16
+ `allow_nested_delegation` list is the boundary. A read-only child started by
17
+ a writable parent now keeps tools such as `grep` that the parent lacks. The
18
+ "Nested tool ceiling" notices are gone. Tests that pinned the ceiling now pin
19
+ the new behaviour.
20
+
21
+ ### Added
22
+
23
+ - `toolBundles` in `squire/settings.json` names shared tool lists; an agent's
24
+ `tools` field uses one as `$name`, for example `tools: [$readonly, pstack_models]`.
25
+ An unknown bundle, a `-$name` entry or an invalid setting disables the agent
26
+ that uses it.
27
+
9
28
  ## [0.16.0] - 2026-11-01
10
29
 
11
30
  ### Breaking
@@ -979,7 +998,8 @@ author's dotfiles; the changes below are against that copy.
979
998
 
980
999
  - The published file list includes `child.ts`, which `index.ts` imports.
981
1000
 
982
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.16.0...HEAD
1001
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.17.0...HEAD
1002
+ [0.17.0]: https://github.com/gvanderclay/pi-squire/compare/v0.16.0...v0.17.0
983
1003
  [0.16.0]: https://github.com/gvanderclay/pi-squire/compare/v0.15.0...v0.16.0
984
1004
  [0.15.0]: https://github.com/gvanderclay/pi-squire/compare/v0.14.0...v0.15.0
985
1005
  [0.14.0]: https://github.com/gvanderclay/pi-squire/compare/v0.13.1...v0.14.0
package/README.md CHANGED
@@ -177,7 +177,7 @@ a path-specific warning. Unknown or unavailable names warn and cannot launch;
177
177
  known entries remain usable, and later roster changes apply normally.
178
178
  Below the limit the grant adds `delegate`,
179
179
  `delegation_status`, `delegation_close` and `delegation_resume` even with an
180
- explicit `tools` list, subject to the nested tool ceiling. Merely listing those
180
+ explicit `tools` list. Merely listing those
181
181
  tools (including by pattern) does not opt in and produces
182
182
  a roster warning. At the depth limit they are inactive and not passed onward.
183
183
 
@@ -187,15 +187,12 @@ agent from the grant blocks future launches/resumes, not existing work.
187
187
  Trusted project agents still shadow global definitions. Each launching session
188
188
  keeps the existing throttled quota checks and shared reactive usage-limit marks.
189
189
 
190
- A nested launch or resume resolves its requested tools, `delegateTools` additions
191
- and automatic nesting grants, then restricts them to operations available to its
192
- direct parent. This includes active model-only operations and registered
193
- codemode/deferred tools, not just displayed schemas. Inactive direct tools and
194
- hidden tools are excluded. Patterns become exact names; removed tools and
195
- restricted patterns are reported in the launch/resume notice. The final
196
- registration allowlist is enforced after synchronous `session:launch`
197
- customization, so additions cannot register or reach tool names outside the
198
- direct parent’s ceiling. Top-level launch selection is unchanged.
190
+ A nested child's tools are worked out as at top level, from its own agent file:
191
+ its `tools` list, or, with no `tools` field, its direct parent's available
192
+ tools (active ones plus codemode/deferred operations), plus `delegateTools` and
193
+ the automatic nesting grant. They are not cut back to the parent's tools. The
194
+ `allow_nested_delegation` list is the boundary: a coordinator that must not
195
+ start a child with more tools lists only children whose agent files give fewer.
199
196
 
200
197
  Each agent keeps its own permission policy; squire does not inherit or compare
201
198
  effective policies. A granting agent with a `permission:` map (even `{}`) warns
@@ -203,7 +200,7 @@ when a listed child lacks one; `true` checks all applicable roster agents.
203
200
  This is only a heuristic warning, not authorization or proof of lesser privilege.
204
201
  Permission policy parsing/enforcement remains the permission provider’s job.
205
202
  Tool-name restrictions and configuration permission rules are not filesystem
206
- isolation: tools such as bash can write files or start processes. The ceiling
203
+ isolation: tools such as bash can write files or start processes. A tool list
207
204
  bounds tool names, not arbitrary extension code or the implementation an
208
205
  extension registers under an allowed name. Rules asking
209
206
  before changes to agent/squire configuration cannot promise interception of
@@ -354,7 +351,7 @@ Only the **direct parent** may resume a child. Ancestor visibility grants no
354
351
  resume authority; forked/cloned/copied history with a new Pi id grants none.
355
352
  Return to the original parent. Identity is read from exact-id durable state,
356
353
  written before launch; inherited launch environment cannot assign a new session
357
- to the old tree. Current allowlists, depth, caps, callable-tool ceiling and
354
+ to the old tree. Current allowlists, depth, caps and
358
355
  directory/source trust still apply to resume, including its picker. A live
359
356
  same-id Squire owner and obsolete local/shared resume chains are refused.
360
357
 
@@ -932,10 +929,10 @@ optional, and the [first-use example](#first-use) is a complete agent file:
932
929
  | `display_name` | Shown instead of the name on the delegate card, in `/delegates` and in the window title (`<display_name>-<label>`). The agent is still called by its name. It may hold only ASCII letters, digits, `-` and `_`; any other value (a space, `#`, `.`, a non-ASCII letter, or an empty value) makes the file invalid, so the agent is disabled. |
933
930
  | `model` | One model: `provider/id` or an alias such as `sonnet`, passed to the child's `--model` unchanged, so Pi resolves it to one model. A `*` pattern makes the file invalid. Absent means the parent session's current model. |
934
931
  | `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. Absent means the parent session's level; an unknown value is dropped with a warning and the parent's level applies. Pi clamps the level to the model. |
935
- | `tools` | The delegate's tools (see below): tool names for exactly those, or only `-name` entries for the parent session's current tools less those. Absent means the parent session's current tools; empty (`tools:` or `tools: []`) means no tools except the `delegateTools` setting. MCP tools follow the selection like any other. |
932
+ | `tools` | The delegate's tools (see below): tool names and `$bundle` entries for exactly those, or only `-name` entries for the parent session's current tools less those. Absent means the parent session's current tools; empty (`tools:` or `tools: []`) means no tools except the `delegateTools` setting. MCP tools follow the selection like any other. |
936
933
  | `fallback` | Models to try, in order, when `model` is usage-limited (see [Usage limits](#usage-limits)). Each entry is one model, like `model`; a `*` pattern, or a YAML list entry holding a comma, makes the file invalid. A comma-separated line (`fallback: a, b`) is two entries. |
937
934
  | `auto_exit` | `true` or `false`, default `true`; see [Auto-exit](#auto-exit). |
938
- | `allow_nested_delegation` | Unreleased: `true` or a YAML list of exact child agent names; grants nested delegation below `maxDepth`, subject to the parent tool ceiling (see [Nested delegation](#nested-delegation-unreleased)). Absent, false or an empty list grants none. Malformed values disable the agent; unavailable names warn. |
935
+ | `allow_nested_delegation` | Unreleased: `true` or a YAML list of exact child agent names; grants nested delegation below `maxDepth` (see [Nested delegation](#nested-delegation-unreleased)). Absent, false or an empty list grants none. Malformed values disable the agent; unavailable names warn. |
939
936
  | `prompt_mode` | `append` (the default) wraps the body in `<agent_instructions>` and `</agent_instructions>` lines; `replace` adds the body unwrapped. An empty body adds nothing either way. |
940
937
  | `enabled` | `false` leaves the agent out. A project file with `enabled: false` hides a global agent of the same name in that project. |
941
938
  | `locked` | `true`, or a list of `model` and `thinking`. A call to `delegate`, `/delegate` or a resume that passes a locked field does not get it: the agent's value is used (the parent's when the file sets none) and the result says which agent locked which field. Passing the agent's own value is the same as passing nothing: no notice, and its fallbacks still apply. A resume of a locked field runs on the file's value as it is now, not on what the delegate last ran on. `true` locks the fields the file sets. |
@@ -973,11 +970,10 @@ loadable by `tool_search`, whatever its exposure. Pi filters MCP tools by
973
970
  such entry pi-squire adds the entry `mcp__`, which names no tool; an empty
974
971
  selection is `--no-tools`, which removes MCP tools too. To give a delegate an
975
972
  MCP server's tools, name them, for example `mcp__docs__*`. An extension that
976
- turns tools on at run time can still add only tools the list allows. Top-level tool names
977
- are not checked against the parent's tools: normal Pi extension discovery
978
- or `session:launch` customization can provide tools in the child. Nested launches
979
- and resumes instead apply the direct parent’s callable-tool ceiling after all
980
- selection and customization, as described above; this is not filesystem isolation.
973
+ turns tools on at run time can still add only tools the list allows. Tool names
974
+ are not checked against the parent's tools, at top level or nested: normal Pi
975
+ extension discovery or `session:launch` customization can provide tools in the
976
+ child.
981
977
 
982
978
  - No `tools` field: the delegate gets the parent session's tools as they are
983
979
  when the launch (or resume) starts: active tools for a top-level launch, or
@@ -988,13 +984,31 @@ selection and customization, as described above; this is not filesystem isolatio
988
984
  except the `delegateTools` entries. With none of those either, it starts
989
985
  with `--no-tools`.
990
986
  - A `tools` list of tool names: exactly those tools, plus the
991
- `delegateTools` entries.
987
+ `delegateTools` entries. A `$name` entry stands for the tools of that
988
+ bundle in the `toolBundles` setting (see below).
992
989
  - A `tools` list of only `-name` entries (`tools: -bash, -write`): the
993
990
  parent's tools, as with no `tools` field, less those, plus the
994
991
  `delegateTools` entries. A `-name` entry may be a `*` pattern
995
992
  (`-mcp__docs__*`).
996
- - A `+name` entry, or tool names and `-name` entries in one list, make the
997
- file invalid, and the agent is disabled (see below).
993
+ - A `+name` entry, a `-$name` entry, a `$name` entry naming no bundle, or
994
+ tool names and `-name` entries in one list, make the file invalid, and the
995
+ agent is disabled (see below).
996
+
997
+ `toolBundles` in `<agent dir>/squire/settings.json` names tool lists that
998
+ several agents share:
999
+
1000
+ ```json
1001
+ { "toolBundles": { "readonly": ["read", "grep", "find", "ls"] } }
1002
+ ```
1003
+
1004
+ An agent then writes `tools: [$readonly, pstack_models]`, which reads as
1005
+ `read, grep, find, ls, pstack_models`; a name listed twice counts once. A
1006
+ bundle name is letters, digits, `_` or `-`; each entry is one tool name or
1007
+ `*` pattern, with no comma and no leading `+`, `-` or `$`, so bundles do not
1008
+ nest. A bundle carries tools only, not permission rules. The setting is read
1009
+ with the roster. A setting that is not an object of such lists disables every
1010
+ agent that uses a bundle, with the reason; agents without bundles are
1011
+ unaffected. (`$`, unlike `@`, may start a plain YAML value.)
998
1012
 
999
1013
  The delegation tools (`delegate`, `delegation_status`, `delegation_close`,
1000
1014
  `delegation_resume`) are removed from ordinary tool selections. An opted-in
@@ -1015,9 +1029,7 @@ the `delegateTools` setting, in `<agent dir>/squire/settings.json` beside
1015
1029
 
1016
1030
  Each entry, a tool name or `*` pattern, is added to every delegate's tools,
1017
1031
  whether they come from its agent's `tools` or from the parent (after any
1018
- `-name` entries are taken off). Top-level launches pass these to `--tools`
1019
- unchanged; nested launches/resumes resolve and cap them by the direct parent’s
1020
- available tools. The setting
1032
+ `-name` entries are taken off), and passed to `--tools` unchanged. The setting
1021
1033
  is empty by default, is read at each launch and resume, and a value that is
1022
1034
  not a list of names, or an entry that holds a comma or starts with `+` or
1023
1035
  `-`, refuses every launch with an error naming the file, before the task is sent or a window
@@ -1064,8 +1076,8 @@ add one.
1064
1076
 
1065
1077
  `<agent dir>/squire/` holds the optional parent guidance file and
1066
1078
  `settings.json` (`maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`,
1067
- `windowPlacement`, `worktreeRoot`, `delegateTools`, `delegateWidgetPlacement` and
1068
- `delegateWidgetMaxEntries`); the
1079
+ `windowPlacement`, `worktreeRoot`, `delegateTools`, `toolBundles`,
1080
+ `delegateWidgetPlacement` and `delegateWidgetMaxEntries`); the
1069
1081
  agents live in `<agent dir>/agents/` (see [Agents](#agents)).
1070
1082
  `delegation_prompt.md` is Markdown, inserted unchanged as the
1071
1083
  `squire_parent_delegation` system-prompt section before each new run, including
@@ -1274,8 +1286,8 @@ success or cancellation envelope.
1274
1286
 
1275
1287
  Once the explicit operation is complete, a legitimate direct-parent resume can
1276
1288
  supersede that session's completed fence as a **new lifetime-counted delegation**.
1277
- It still passes current authority/owner, chain, depth, grants, caps, tool-ceiling
1278
- and trust checks. Failed pre-dispatch resumes leave the old fence effective.
1289
+ It still passes current authority/owner, chain, depth, grants, caps and trust
1290
+ checks. Failed pre-dispatch resumes leave the old fence effective.
1279
1291
  Closed descendants remain canceled; resuming a coordinator does not revive them.
1280
1292
  An unresolved active close cannot be bypassed by resume.
1281
1293
 
@@ -1584,7 +1596,7 @@ and it resumes as the command does.
1584
1596
  receives it once its mailbox starts. If the send fails, the new window is
1585
1597
  killed and nothing is recorded.
1586
1598
  - Only its direct parent may resume it, preserving the original root/depth.
1587
- The current parent grant, depth/cap/tool-ceiling/trust checks are reapplied.
1599
+ The current parent grant, depth/cap/trust checks are reapplied.
1588
1600
  A chain resumes from its newest local/shared delegation; older records and
1589
1601
  copied fork/clone history are refused, naming the owner or newest record.
1590
1602
  - It is refused, before anything is sent or opened, unless: the delegation is a
@@ -1954,7 +1966,7 @@ Other extensions may rely on:
1954
1966
  - the agent file's frontmatter keys and its locations,
1955
1967
  `<agent dir>/agents/<name>.md` and, in a trusted project,
1956
1968
  `<cwd>/.pi/agents/<name>.md`;
1957
- - the `maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`, `delegateTools`,
1969
+ - the `maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`, `delegateTools`, `toolBundles`,
1958
1970
  `windowPlacement`, `worktreeRoot`, `delegateWidgetPlacement` and
1959
1971
  `delegateWidgetMaxEntries` settings in
1960
1972
  `<agent dir>/squire/settings.json`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
package/src/agents.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  // holding a comma, makes the file invalid; absent `model` means the parent's),
12
12
  // `thinking` (absent or unknown means the parent's level), `tools` (the
13
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),
14
+ // none; a list entry holding a comma makes the file invalid; a `$name` entry
15
+ // stands for that `toolBundles` bundle's tools), `auto_exit` (default true),
15
16
  // `prompt_mode` (`append`, the default, or `replace`), `enabled` (false leaves
16
17
  // the agent out) and `locked` (true, or a list of `model` and `thinking`). Any
17
18
  // other key is ignored, left for whatever else reads the file. The body is the
@@ -21,7 +22,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
22
  import { join } from "node:path";
22
23
 
23
24
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
24
- import { toolMatches } from "./tool-ceiling.ts";
25
+ import { type Bundles, expandBundles, toolBundles } from "./tool-bundles.ts";
26
+ import { toolMatches } from "./tool-names.ts";
25
27
 
26
28
  /** The levels `pi --thinking` accepts. */
27
29
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -106,7 +108,7 @@ const field = (frontmatter: Record<string, unknown>, key: string): string | unde
106
108
  type Parsed = { agent?: Agent; invalid?: string; notes: string[] };
107
109
 
108
110
  /** 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 {
111
+ function parseAgent(name: string, path: string, text: string, bundles: Bundles): Parsed {
110
112
  let frontmatter: unknown;
111
113
  let body: string;
112
114
  try {
@@ -130,7 +132,7 @@ function parseAgent(name: string, path: string, text: string): Parsed {
130
132
  invalid: `${path} has allow_nested_delegation that is not true, false or a list of agent names`,
131
133
  notes: [],
132
134
  };
133
- const launch = toolsField(path, fields);
135
+ const launch = toolsField(path, fields, bundles);
134
136
  if ("invalid" in launch) return { ...launch, notes: [] };
135
137
  const model = field(fields, "model");
136
138
  const fallback = toolList(fields.fallback);
@@ -272,14 +274,15 @@ function lockedFields(
272
274
  }
273
275
 
274
276
  /**
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.
277
+ * The `tools` field: tool names (`*` patterns allowed) and `$bundle` entries
278
+ * are the whole list; only `-name` entries take those off the parent's tools.
279
+ * A `+name` entry, names and `-name` entries mixed, an unknown bundle, or a
280
+ * YAML list entry holding a comma make the file invalid.
279
281
  */
280
282
  function toolsField(
281
283
  path: string,
282
284
  frontmatter: Record<string, unknown>,
285
+ bundles: Bundles,
283
286
  ): { tools?: string[]; toolsExcept: string[] } | { invalid: string } {
284
287
  // `tools:` with no value is YAML null: present and empty, unlike an absent field.
285
288
  const tools = Object.hasOwn(frontmatter, "tools") && frontmatter.tools === null ? [] : toolList(frontmatter.tools);
@@ -298,8 +301,13 @@ function toolsField(
298
301
  return {
299
302
  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
303
  };
304
+ const minusBundle = tools.find((name) => name.startsWith("-$"));
305
+ if (minusBundle !== undefined)
306
+ return {
307
+ invalid: `${path} has tools entry ${JSON.stringify(minusBundle)}: a bundle cannot be taken off; take its tools off by name`,
308
+ };
301
309
  const minus = tools.filter((name) => name.startsWith("-"));
302
- if (minus.length === 0) return { tools, toolsExcept: [] };
310
+ if (minus.length === 0) return expandBundles(path, tools, bundles);
303
311
  if (minus.length < tools.length)
304
312
  return {
305
313
  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`,
@@ -371,6 +379,7 @@ export function readRoster(agentDir: string, projectDir?: string): Roster {
371
379
  const agents: Agent[] = [];
372
380
  const disabled: Disabled[] = [];
373
381
  const warnings: string[] = [];
382
+ const bundles = toolBundles(agentDir);
374
383
  const retired = join(agentDir, "squire", "agents");
375
384
  if (existsSync(retired))
376
385
  warnings.push(
@@ -393,7 +402,7 @@ export function readRoster(agentDir: string, projectDir?: string): Roster {
393
402
  disable(name, path, `${path} could not be read (${(err as Error).message})`);
394
403
  continue;
395
404
  }
396
- const { agent, invalid, notes } = parseAgent(name, path, text);
405
+ const { agent, invalid, notes } = parseAgent(name, path, text, bundles);
397
406
  warnings.push(...notes);
398
407
  if (agent !== undefined) agents.push(agent);
399
408
  else if (invalid !== undefined) disable(name, path, invalid);
package/src/index.ts CHANGED
@@ -78,7 +78,7 @@ import { pickResume } from "./resume-picker.ts";
78
78
  import { ownedAPI, SessionOwner, sessionIsOwned } from "./session-owner.ts";
79
79
  import { registerTaskReplies } from "./task-replies.ts";
80
80
  import { createTmuxClient, type OpenedWindow, requireTmuxVersion, type TmuxClient, WindowOpenError } from "./tmux.ts";
81
- import { availableTools, enforceToolCeiling } from "./tool-ceiling.ts";
81
+ import { availableTools } from "./tool-names.ts";
82
82
  import { createTracking, toolResult } from "./tracking.ts";
83
83
  import { treeNotice } from "./tree-notice.ts";
84
84
  import {
@@ -1044,22 +1044,6 @@ export default function delegate(
1044
1044
  for (const key of ["parent", "root", "depth", "chain"] as const)
1045
1045
  Object.defineProperty(payload, key, { writable: false, configurable: false });
1046
1046
  pi.events.emit(LAUNCH, payload);
1047
- if (identity.current()) {
1048
- const prefix = parentCommand().length;
1049
- const args = payload.args.slice(prefix);
1050
- enforceToolCeiling({
1051
- args,
1052
- available: availableTools(pi).filter(
1053
- (name) => grantsNesting(start.agent, own.depth) || !DELEGATION_TOOLS.has(name),
1054
- ),
1055
- registered: pi.getAllTools().map((tool) => tool.name),
1056
- cwd,
1057
- agentDir: payload.env.PI_CODING_AGENT_DIR,
1058
- trusted,
1059
- notices: start.notices,
1060
- });
1061
- payload.args.splice(prefix, payload.args.length - prefix, ...args);
1062
- }
1063
1047
  const token: Reservation = { root: own.root, id };
1064
1048
  bindTree(token, { windowName: name, cwd, workspace });
1065
1049
  dispatchTree(token);
@@ -0,0 +1,84 @@
1
+ // Tool bundles: named tool lists in `<agent dir>/squire/settings.json`
2
+ // `toolBundles`, which an agent's `tools` field names as `$name`. A bundle is
3
+ // only shorthand for tool names; it carries no permission rules.
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ /** A bundle name: the same rule as an agent name. */
8
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
9
+
10
+ /**
11
+ * Replace each `$name` entry with the tools of the `toolBundles` bundle it
12
+ * names, keeping the first of any repeated name. An unknown bundle, or any
13
+ * bundle while the setting is invalid, makes the file invalid.
14
+ */
15
+ export function expandBundles(
16
+ path: string,
17
+ tools: string[],
18
+ bundles: Bundles,
19
+ ): { tools: string[]; toolsExcept: string[] } | { invalid: string } {
20
+ const expanded: string[] = [];
21
+ for (const entry of tools) {
22
+ if (!entry.startsWith("$")) {
23
+ expanded.push(entry);
24
+ continue;
25
+ }
26
+ const names = bundles.names.get(entry.slice(1));
27
+ if (names === undefined)
28
+ return {
29
+ invalid:
30
+ bundles.problem === undefined
31
+ ? `${path} has tools entry ${JSON.stringify(entry)}, but toolBundles in ${bundles.file} defines no bundle ${JSON.stringify(entry.slice(1))}`
32
+ : `${path} has tools entry ${JSON.stringify(entry)}, but ${bundles.problem}`,
33
+ };
34
+ expanded.push(...names);
35
+ }
36
+ return { tools: [...new Set(expanded)], toolsExcept: [] };
37
+ }
38
+
39
+ /** The `toolBundles` setting: tool lists by bundle name, or why the setting cannot be used. */
40
+ export type Bundles = { file: string; names: ReadonlyMap<string, readonly string[]>; problem?: string };
41
+
42
+ /**
43
+ * The `toolBundles` setting in `<agent dir>/squire/settings.json`: named lists
44
+ * of tool names or `*` patterns an agent's `tools` field names as `$name`.
45
+ * Empty when the file or setting is absent. A setting that is not an object of
46
+ * name lists, or a bundle entry that holds a comma or starts with `+`, `-` or
47
+ * `$` (bundles do not nest), leaves no bundles and says why.
48
+ */
49
+ export function toolBundles(agentDir: string): Bundles {
50
+ const file = join(agentDir, "squire", "settings.json");
51
+ const none = (problem?: string): Bundles => ({ file, names: new Map(), ...(problem ? { problem } : {}) });
52
+ let text: string;
53
+ try {
54
+ text = readFileSync(file, "utf8");
55
+ } catch {
56
+ return none();
57
+ }
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = JSON.parse(text);
61
+ } catch {
62
+ return none(`${file} is not valid JSON, so its toolBundles cannot be read`);
63
+ }
64
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return none();
65
+ const value = (parsed as Record<string, unknown>).toolBundles;
66
+ if (value === undefined) return none();
67
+ const invalid = (why: string): Bundles => none(`toolBundles in ${file} is invalid: ${why}`);
68
+ if (typeof value !== "object" || value === null || Array.isArray(value))
69
+ return invalid("it must be an object of bundle names to lists of tool names");
70
+ const names = new Map<string, string[]>();
71
+ for (const [name, list] of Object.entries(value)) {
72
+ if (!NAME.test(name)) return invalid(`bundle name ${JSON.stringify(name)} is not letters, digits, _ or -`);
73
+ if (!Array.isArray(list) || !list.every((entry) => typeof entry === "string" && entry.trim() !== ""))
74
+ return invalid(`bundle ${JSON.stringify(name)} must be a list of tool names or patterns`);
75
+ const tools = list.map((entry: string) => entry.trim());
76
+ const bad = tools.find((tool) => tool.includes(",") || /^[+\-$]/.test(tool));
77
+ if (bad !== undefined)
78
+ return invalid(
79
+ `bundle ${JSON.stringify(name)} entry ${JSON.stringify(bad)} must be one tool name or pattern, with no comma and no leading +, - or $`,
80
+ );
81
+ names.set(name, tools);
82
+ }
83
+ return { file, names };
84
+ }
@@ -0,0 +1,29 @@
1
+ // Tool-name helpers shared by the roster and launch code.
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Match Pi's tool-list glob: only * has special meaning. */
5
+ export function toolMatches(pattern: string, name: string): boolean {
6
+ const source = pattern
7
+ .split("*")
8
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
9
+ .join(".*");
10
+ return new RegExp(`^${source}$`).test(name);
11
+ }
12
+
13
+ /**
14
+ * The operations this session can reach: active tools plus codemode and
15
+ * deferred ones, never hidden ones. A nested delegate with no `tools` field
16
+ * inherits these. Includes active model-only operations, though
17
+ * ctx.executeTool cannot call those.
18
+ */
19
+ export function availableTools(pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">): string[] {
20
+ const active = new Set(pi.getActiveTools());
21
+ return pi
22
+ .getAllTools()
23
+ .filter(
24
+ (tool) =>
25
+ tool.exposure !== "hidden" &&
26
+ (active.has(tool.name) || tool.exposure === "codemode" || tool.exposure === "deferred"),
27
+ )
28
+ .map((tool) => tool.name);
29
+ }
@@ -1,125 +0,0 @@
1
- // A nested launch is bounded by operations its direct parent can reach, not
2
- // by the schemas currently displayed. CLI registration filtering also covers
3
- // deferred tools and tools registered later by launch-time extensions.
4
- import { type Args, type ExtensionAPI, parseArgs, SettingsManager } from "@earendil-works/pi-coding-agent";
5
-
6
- /** Match Pi's tool-list glob: only * has special meaning. */
7
- export function toolMatches(pattern: string, name: string): boolean {
8
- const source = pattern
9
- .split("*")
10
- .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
11
- .join(".*");
12
- return new RegExp(`^${source}$`).test(name);
13
- }
14
-
15
- /** Includes active model-only operations, though ctx.executeTool cannot call those. */
16
- export function availableTools(pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">): string[] {
17
- const active = new Set(pi.getActiveTools());
18
- return pi
19
- .getAllTools()
20
- .filter(
21
- (tool) =>
22
- tool.exposure !== "hidden" &&
23
- (active.has(tool.name) || tool.exposure === "codemode" || tool.exposure === "deferred"),
24
- )
25
- .map((tool) => tool.name);
26
- }
27
-
28
- type CeilingInput = {
29
- args: string[];
30
- available: readonly string[];
31
- registered?: readonly string[];
32
- cwd: string;
33
- agentDir: string;
34
- trusted: boolean;
35
- notices: string[];
36
- };
37
-
38
- function requestedTools(parsed: Args, input: CeilingInput): string[] {
39
- const requested = parsed.tools ?? [];
40
- if (!requested.some((name) => name.startsWith("+") || name.startsWith("-"))) return requested;
41
- // Pi modifiers replace the earlier --tools selection and modify the
42
- // child's defaults. Use Pi's trusted settings reader, not the parent's defaults.
43
- const defaults =
44
- parsed.noTools || parsed.noBuiltinTools
45
- ? []
46
- : (SettingsManager.create(input.cwd, input.agentDir, { projectTrusted: input.trusted }).getDefaultTools() ?? [
47
- "read",
48
- "bash",
49
- "edit",
50
- "write",
51
- ]);
52
- const selected = new Set(defaults);
53
- for (const entry of requested) {
54
- if (entry.startsWith("+")) selected.add(entry.slice(1));
55
- else selected.delete(entry.slice(1));
56
- }
57
- return [...selected];
58
- }
59
-
60
- function ceilingNotice(input: CeilingInput, requested: string[], exclusions: string[]): void {
61
- const { available } = input;
62
- const removed = [
63
- ...new Set([
64
- ...requested.filter((pattern) => pattern !== "mcp__" && !pattern.includes("*") && !available.includes(pattern)),
65
- ...(input.registered ?? []).filter(
66
- (name) => !available.includes(name) && requested.some((pattern) => toolMatches(pattern, name)),
67
- ),
68
- ]),
69
- ].filter((name) => !exclusions.some((pattern) => toolMatches(pattern, name)));
70
- // Child-only and future registrations cannot be enumerated in the parent.
71
- const bounded = [...new Set(requested.filter((pattern) => pattern.includes("*")))];
72
- if (!removed.length && !bounded.length) return;
73
- const parts = ["Nested tool ceiling:"];
74
- if (removed.length) parts.push(`removed unavailable tools ${removed.join(", ")}.`);
75
- if (bounded.length)
76
- parts.push(`Restricted patterns ${bounded.join(", ")} to exact tools available to the direct parent.`);
77
- input.notices.push(parts.join(" "));
78
- }
79
-
80
- /** Find an option terminator, not a -- token consumed as another flag’s value. */
81
- function optionBoundary(args: string[]): number {
82
- let marker = "--squire-ceiling-boundary";
83
- while (args.includes(marker)) marker += "-";
84
- for (const [index, arg] of args.entries()) {
85
- if (arg === "--" && !parseArgs([...args.slice(0, index + 1), marker]).unknownFlags.has(marker.slice(2)))
86
- return index;
87
- }
88
- return args.length;
89
- }
90
-
91
- function exactName(name: string): boolean {
92
- return !!name && name.trim() === name && !name.includes("*") && !name.includes(",") && !/^[+-]/.test(name);
93
- }
94
-
95
- /**
96
- * Resolve the final customized CLI selection, then pin an exact registration
97
- * allowlist. Run after all synchronous session:launch listeners, before spawn.
98
- * args excludes the executable/launcher prefix. Unrelated args stay unchanged.
99
- */
100
- export function enforceToolCeiling(input: CeilingInput): void {
101
- const { args, available } = input;
102
- const parsed = parseArgs(args);
103
- const errors = parsed.diagnostics.filter((item) => item.type === "error");
104
- if (errors.length)
105
- throw new Error(`invalid nested launch arguments: ${errors.map((item) => item.message).join("; ")}`);
106
- const requested = requestedTools(parsed, input);
107
- const exclusions = parsed.excludeTools ?? [];
108
- const allowed = available.filter(
109
- (name) =>
110
- requested.some((pattern) => toolMatches(pattern, name)) &&
111
- !exclusions.some((pattern) => toolMatches(pattern, name)),
112
- );
113
- // Never turn a wildcard in a registered name into a grant pattern.
114
- if (!allowed.every(exactName))
115
- throw new Error("cannot encode the parent's tool names as an exact nested tool ceiling");
116
- ceilingNotice(input, requested, exclusions);
117
- const entries = allowed.some((name) => name.startsWith("mcp__")) ? allowed : [...allowed, "mcp__"];
118
- // --no-tools alone would leave a previous --tools value authoritative.
119
- const final = allowed.length ? ["--tools", entries.join(",")] : ["--tools", "", "--no-tools"];
120
- args.splice(optionBoundary(args), 0, ...final);
121
- const effective = parseArgs(args).tools;
122
- const expected = allowed.length ? entries : [];
123
- if (!effective || effective.length !== expected.length || effective.some((name, index) => name !== expected[index]))
124
- throw new Error("could not apply the nested tool ceiling to customized launch arguments");
125
- }