pi-squire 0.16.0 → 0.18.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,35 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.18.0] - 2026-11-01
10
+
11
+ ### Changed
12
+
13
+ - New root companion sessions deliberately default to `<project>-delegates`
14
+ instead of `<project>-delegate-session`. Set `companionSuffix` in
15
+ `<agent dir>/squire/settings.json` to use another suffix, such as `playpen`.
16
+ Existing companions and saved identities keep their names, including for
17
+ nested launches and resumes.
18
+
19
+ ## [0.17.0] - 2026-11-01
20
+
21
+ ### Breaking
22
+
23
+ - Nested launches and resumes no longer cap a child's tools to its direct
24
+ parent's. A nested child gets the tools its own agent file gives, as at top
25
+ level, and `session:launch` additions are no longer cut back afterwards; the
26
+ `allow_nested_delegation` list is the boundary. A read-only child started by
27
+ a writable parent now keeps tools such as `grep` that the parent lacks. The
28
+ "Nested tool ceiling" notices are gone. Tests that pinned the ceiling now pin
29
+ the new behaviour.
30
+
31
+ ### Added
32
+
33
+ - `toolBundles` in `squire/settings.json` names shared tool lists; an agent's
34
+ `tools` field uses one as `$name`, for example `tools: [$readonly, pstack_models]`.
35
+ An unknown bundle, a `-$name` entry or an invalid setting disables the agent
36
+ that uses it.
37
+
9
38
  ## [0.16.0] - 2026-11-01
10
39
 
11
40
  ### Breaking
@@ -979,7 +1008,9 @@ author's dotfiles; the changes below are against that copy.
979
1008
 
980
1009
  - The published file list includes `child.ts`, which `index.ts` imports.
981
1010
 
982
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.16.0...HEAD
1011
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.18.0...HEAD
1012
+ [0.18.0]: https://github.com/gvanderclay/pi-squire/compare/v0.17.0...v0.18.0
1013
+ [0.17.0]: https://github.com/gvanderclay/pi-squire/compare/v0.16.0...v0.17.0
983
1014
  [0.16.0]: https://github.com/gvanderclay/pi-squire/compare/v0.15.0...v0.16.0
984
1015
  [0.15.0]: https://github.com/gvanderclay/pi-squire/compare/v0.14.0...v0.15.0
985
1016
  [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,21 +351,21 @@ 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
 
361
358
  ## Where delegate windows open
362
359
 
363
360
  By default a delegate's window opens in a shared **project companion session**
364
- named `<project>-delegate-session` on your existing tmux server. The owning
361
+ named `<project>-delegates` on your existing tmux server. The owning
365
362
  root Pi session's project chooses it, not the parent tmux session or the
366
363
  delegate's working directory. Launching into another repository or creating a
367
364
  worktree there still uses the root's companion; resuming a delegate does too.
368
365
 
369
366
  Nested launches inherit the original root project: A → coordinator in B →
370
367
  worker in C all select A's companion. The preferred base name (for example,
371
- `A-delegate-session`) is saved as `rootPlacement` in the existing durable
368
+ `A-delegates`) is saved as `rootPlacement` in the existing durable
372
369
  delegation identity and shared tree records, not recomputed from B/C or the
373
370
  containing tmux session. Reopening the **same** coordinator session without its
374
371
  launch environment restores that preference; it can launch or directly resume
@@ -387,7 +384,7 @@ uses the root working-directory basename instead.
387
384
  Project names preserve ASCII letters (including case), digits, hyphens and
388
385
  underscores. Runs of other characters become one hyphen, edge hyphens are
389
386
  trimmed, and an empty name becomes `project`: `Client API` uses
390
- `Client-API-delegate-session`.
387
+ `Client-API-delegates`.
391
388
 
392
389
  Multiple roots share a companion even across parent tmux sessions. Distinct
393
390
  paths with the same basename, separate clones named alike, and names that
@@ -397,12 +394,13 @@ each root.
397
394
 
398
395
  pi-squire verifies the tmux session option `@squire-companion` before reusing
399
396
  an occupied name. An unmarked session is left untouched; numbered alternatives
400
- start at `<project>-delegate-session-2`, skipping unmarked alternatives too.
397
+ start at `<project>-<suffix>-2`, skipping unmarked alternatives too.
401
398
  Later launches reuse an existing managed alternative even if a lower name
402
399
  becomes free. Unexpected duplicate managed candidates use the first in numeric
403
- order, without moving windows or repairing sessions. Existing legacy companions
404
- and live windows are neither renamed nor migrated; only new launches and
405
- resumes use project naming.
400
+ order, without moving windows or repairing sessions. Existing companions,
401
+ live windows and saved identities keep their names, including the former
402
+ `<project>-delegate-session` default. Nested launches and resumes retain the
403
+ saved preference; companions are not renamed or migrated.
406
404
 
407
405
  - The session is created on demand, by the launch that needs it, and tmux
408
406
  removes it with its last window, so it does not outlive its delegates.
@@ -419,6 +417,22 @@ resumes use project naming.
419
417
  up each time, so it follows a window you move to another session, which is
420
418
  still found, closed and resumed.
421
419
 
420
+ To use a different suffix, set `companionSuffix` in
421
+ `<agent dir>/squire/settings.json`:
422
+
423
+ ```json
424
+ { "companionSuffix": "playpen" }
425
+ ```
426
+
427
+ This names a new root's companion `<project>-playpen`. The default suffix is
428
+ `delegates`. A suffix must contain 1 to 32 ASCII letters, digits, underscores
429
+ or hyphens, with no leading or trailing hyphen. It cannot be entirely numeric
430
+ or end in a hyphen followed by digits, such as `2` or `x-10`, because those
431
+ names overlap with numbered alternatives. An invalid suffix refuses the launch
432
+ with an error naming the file, before the task is sent or a window opens.
433
+ The suffix is read with `windowPlacement` at launch. It does not replace a
434
+ saved root preference for nested launches or resumes.
435
+
422
436
  To open windows in your own session, as before, set `windowPlacement` in
423
437
  `<agent dir>/squire/settings.json`, beside `worktreeRoot`:
424
438
 
@@ -932,10 +946,10 @@ optional, and the [first-use example](#first-use) is a complete agent file:
932
946
  | `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
947
  | `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
948
  | `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. |
949
+ | `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
950
  | `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
951
  | `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. |
952
+ | `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
953
  | `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
954
  | `enabled` | `false` leaves the agent out. A project file with `enabled: false` hides a global agent of the same name in that project. |
941
955
  | `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 +987,10 @@ loadable by `tool_search`, whatever its exposure. Pi filters MCP tools by
973
987
  such entry pi-squire adds the entry `mcp__`, which names no tool; an empty
974
988
  selection is `--no-tools`, which removes MCP tools too. To give a delegate an
975
989
  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.
990
+ turns tools on at run time can still add only tools the list allows. Tool names
991
+ are not checked against the parent's tools, at top level or nested: normal Pi
992
+ extension discovery or `session:launch` customization can provide tools in the
993
+ child.
981
994
 
982
995
  - No `tools` field: the delegate gets the parent session's tools as they are
983
996
  when the launch (or resume) starts: active tools for a top-level launch, or
@@ -988,13 +1001,31 @@ selection and customization, as described above; this is not filesystem isolatio
988
1001
  except the `delegateTools` entries. With none of those either, it starts
989
1002
  with `--no-tools`.
990
1003
  - A `tools` list of tool names: exactly those tools, plus the
991
- `delegateTools` entries.
1004
+ `delegateTools` entries. A `$name` entry stands for the tools of that
1005
+ bundle in the `toolBundles` setting (see below).
992
1006
  - A `tools` list of only `-name` entries (`tools: -bash, -write`): the
993
1007
  parent's tools, as with no `tools` field, less those, plus the
994
1008
  `delegateTools` entries. A `-name` entry may be a `*` pattern
995
1009
  (`-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).
1010
+ - A `+name` entry, a `-$name` entry, a `$name` entry naming no bundle, or
1011
+ tool names and `-name` entries in one list, make the file invalid, and the
1012
+ agent is disabled (see below).
1013
+
1014
+ `toolBundles` in `<agent dir>/squire/settings.json` names tool lists that
1015
+ several agents share:
1016
+
1017
+ ```json
1018
+ { "toolBundles": { "readonly": ["read", "grep", "find", "ls"] } }
1019
+ ```
1020
+
1021
+ An agent then writes `tools: [$readonly, pstack_models]`, which reads as
1022
+ `read, grep, find, ls, pstack_models`; a name listed twice counts once. A
1023
+ bundle name is letters, digits, `_` or `-`; each entry is one tool name or
1024
+ `*` pattern, with no comma and no leading `+`, `-` or `$`, so bundles do not
1025
+ nest. A bundle carries tools only, not permission rules. The setting is read
1026
+ with the roster. A setting that is not an object of such lists disables every
1027
+ agent that uses a bundle, with the reason; agents without bundles are
1028
+ unaffected. (`$`, unlike `@`, may start a plain YAML value.)
998
1029
 
999
1030
  The delegation tools (`delegate`, `delegation_status`, `delegation_close`,
1000
1031
  `delegation_resume`) are removed from ordinary tool selections. An opted-in
@@ -1015,9 +1046,7 @@ the `delegateTools` setting, in `<agent dir>/squire/settings.json` beside
1015
1046
 
1016
1047
  Each entry, a tool name or `*` pattern, is added to every delegate's tools,
1017
1048
  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
1049
+ `-name` entries are taken off), and passed to `--tools` unchanged. The setting
1021
1050
  is empty by default, is read at each launch and resume, and a value that is
1022
1051
  not a list of names, or an entry that holds a comma or starts with `+` or
1023
1052
  `-`, refuses every launch with an error naming the file, before the task is sent or a window
@@ -1064,8 +1093,8 @@ add one.
1064
1093
 
1065
1094
  `<agent dir>/squire/` holds the optional parent guidance file and
1066
1095
  `settings.json` (`maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`,
1067
- `windowPlacement`, `worktreeRoot`, `delegateTools`, `delegateWidgetPlacement` and
1068
- `delegateWidgetMaxEntries`); the
1096
+ `windowPlacement`, `companionSuffix`, `worktreeRoot`, `delegateTools`, `toolBundles`,
1097
+ `delegateWidgetPlacement` and `delegateWidgetMaxEntries`); the
1069
1098
  agents live in `<agent dir>/agents/` (see [Agents](#agents)).
1070
1099
  `delegation_prompt.md` is Markdown, inserted unchanged as the
1071
1100
  `squire_parent_delegation` system-prompt section before each new run, including
@@ -1274,8 +1303,8 @@ success or cancellation envelope.
1274
1303
 
1275
1304
  Once the explicit operation is complete, a legitimate direct-parent resume can
1276
1305
  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.
1306
+ It still passes current authority/owner, chain, depth, grants, caps and trust
1307
+ checks. Failed pre-dispatch resumes leave the old fence effective.
1279
1308
  Closed descendants remain canceled; resuming a coordinator does not revive them.
1280
1309
  An unresolved active close cannot be bypassed by resume.
1281
1310
 
@@ -1584,7 +1613,7 @@ and it resumes as the command does.
1584
1613
  receives it once its mailbox starts. If the send fails, the new window is
1585
1614
  killed and nothing is recorded.
1586
1615
  - 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.
1616
+ The current parent grant, depth/cap/trust checks are reapplied.
1588
1617
  A chain resumes from its newest local/shared delegation; older records and
1589
1618
  copied fork/clone history are refused, naming the owner or newest record.
1590
1619
  - It is refused, before anything is sent or opened, unless: the delegation is a
@@ -1954,8 +1983,8 @@ Other extensions may rely on:
1954
1983
  - the agent file's frontmatter keys and its locations,
1955
1984
  `<agent dir>/agents/<name>.md` and, in a trusted project,
1956
1985
  `<cwd>/.pi/agents/<name>.md`;
1957
- - the `maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`, `delegateTools`,
1958
- `windowPlacement`, `worktreeRoot`, `delegateWidgetPlacement` and
1986
+ - the `maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`, `delegateTools`, `toolBundles`,
1987
+ `windowPlacement`, `companionSuffix`, `worktreeRoot`, `delegateWidgetPlacement` and
1959
1988
  `delegateWidgetMaxEntries` settings in
1960
1989
  `<agent dir>/squire/settings.json`;
1961
1990
  - the optional `<agent dir>/squire/delegation_prompt.md` file and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.16.0",
3
+ "version": "0.18.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 {
@@ -100,7 +100,7 @@ import { registerTrustControl } from "./trust-control.ts";
100
100
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
101
101
  import { registerDelegateWidget } from "./widget.ts";
102
102
  import { registerActivityRuntime } from "./widget-activity-runtime.ts";
103
- import { assertRootPlacement, placementProject, type WindowPlacement, windowPlacement } from "./window-placement.ts";
103
+ import { assertRootPlacement, placementProject, type WindowPlacement, windowSettings } from "./window-placement.ts";
104
104
  import {
105
105
  normalizeWorkspaceValue,
106
106
  prepareDirectory,
@@ -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);
@@ -1163,11 +1147,11 @@ export default function delegate(
1163
1147
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
1164
1148
  await requireTmuxVersion(tmux);
1165
1149
  // Read once, before anything is created or mailed; the launch keeps these values.
1166
- const placement = windowPlacement(getAgentDir());
1150
+ const { placement, companionSuffix } = windowSettings(getAgentDir());
1167
1151
  const parentIdentity = identity.current();
1168
1152
  const project = parentIdentity
1169
1153
  ? { companion: parentIdentity.rootPlacement, notices: [] }
1170
- : placementProject(ctx.cwd);
1154
+ : placementProject(ctx.cwd, companionSuffix);
1171
1155
  assertRootPlacement(project.companion);
1172
1156
  const own = identity.child(start.agent, ctx, project.companion);
1173
1157
  if (parentIdentity) assertParentPlacement(readTree(own.root), own);
@@ -1382,7 +1366,7 @@ export default function delegate(
1382
1366
  delegateSchema: DelegateSchema,
1383
1367
  notInTmux: NOT_IN_TMUX,
1384
1368
  startPolling: tracking.startPolling,
1385
- placement: () => windowPlacement(getAgentDir()),
1369
+ placement: () => windowSettings(getAgentDir()).placement,
1386
1370
  roster: rosterFor,
1387
1371
  admit: (agent, ctx) => admit(agent, currentRoster(ctx)),
1388
1372
  authorize: (id, ctx) => {
@@ -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,6 +1,6 @@
1
1
  // Call-time choice of where delegate windows open. `companion` (the default)
2
- // opens them in a shared project session; `current`
3
- // opens them in the parent's own session, as before. Read from
2
+ // opens them in a shared project session named `<project>-<companionSuffix>`
3
+ // (default suffix `delegates`); `current` opens in the parent's own session. Read from
4
4
  // `<agent dir>/squire/settings.json` at every launch, like `worktreeRoot`.
5
5
  import { readFileSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
@@ -9,21 +9,25 @@ import { inspectGit } from "./git.ts";
9
9
 
10
10
  export type WindowPlacement = "companion" | "current";
11
11
 
12
+ const DEFAULT_SUFFIX = "delegates";
13
+ const safeName = /^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$/;
14
+ const numberedEnding = /(^|-)[0-9]+$/;
15
+
12
16
  /** A preferred base, never a selected numbered companion or a tmux locator. */
13
17
  export function assertRootPlacement(value: unknown): asserts value is string {
14
- if (typeof value !== "string" || !/^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?-delegate-session$/.test(value))
18
+ if (typeof value !== "string" || !safeName.test(value) || !value.includes("-") || numberedEnding.test(value))
15
19
  throw new Error(
16
20
  "invalid or missing rootPlacement in delegation identity; original root companion preference is required",
17
21
  );
18
22
  }
19
23
 
20
24
  /** The owning root's project, never the delegate's chosen working directory. */
21
- export function placementProject(cwd: string): { companion: string; notices: string[] } {
25
+ export function placementProject(cwd: string, suffix = DEFAULT_SUFFIX): { companion: string; notices: string[] } {
22
26
  const git = inspectGit(cwd);
23
27
  const project = basename(git.kind === "git" ? git.identity.mainRoot : cwd);
24
28
  const normalized = project.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
25
29
  return {
26
- companion: `${normalized}-delegate-session`,
30
+ companion: `${normalized}-${suffix}`,
27
31
  notices:
28
32
  git.kind === "unknown"
29
33
  ? [
@@ -38,19 +42,32 @@ export function placementProject(cwd: string): { companion: string; notices: str
38
42
  * This reader defaults malformed/unreadable files to companion; mandatory
39
43
  * tree-cap admission separately refuses those files for every launch/resume.
40
44
  */
41
- export function windowPlacement(agentDir: string): WindowPlacement {
45
+ export function windowSettings(agentDir: string): { placement: WindowPlacement; companionSuffix: string } {
42
46
  const file = join(agentDir, "squire", "settings.json");
43
47
  let parsed: unknown;
44
48
  try {
45
49
  parsed = JSON.parse(readFileSync(file, "utf8"));
46
50
  } catch {
47
- return "companion";
51
+ return { placement: "companion", companionSuffix: DEFAULT_SUFFIX };
48
52
  }
49
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "companion";
50
- const value = (parsed as Record<string, unknown>).windowPlacement;
51
- if (value === undefined || value === "companion") return "companion";
52
- if (value === "current") return "current";
53
- throw new Error(
54
- `invalid window placement settings ${file}: windowPlacement must be "companion" or "current", not ${JSON.stringify(value)}`,
55
- );
53
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
54
+ return { placement: "companion", companionSuffix: DEFAULT_SUFFIX };
55
+ const { windowPlacement: placement = "companion", companionSuffix = DEFAULT_SUFFIX } = parsed as Record<
56
+ string,
57
+ unknown
58
+ >;
59
+ if (placement !== "companion" && placement !== "current")
60
+ throw new Error(
61
+ `invalid window placement settings ${file}: windowPlacement must be "companion" or "current", not ${JSON.stringify(placement)}`,
62
+ );
63
+ if (
64
+ typeof companionSuffix !== "string" ||
65
+ companionSuffix.length > 32 ||
66
+ !safeName.test(companionSuffix) ||
67
+ numberedEnding.test(companionSuffix)
68
+ )
69
+ throw new Error(
70
+ `invalid window placement settings ${file}: companionSuffix must be 1 to 32 ASCII letters, digits, underscores or hyphens, with no edge hyphen or numeric final segment, not ${JSON.stringify(companionSuffix)}`,
71
+ );
72
+ return { placement, companionSuffix };
56
73
  }
@@ -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
- }