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 +32 -1
- package/README.md +68 -39
- package/package.json +1 -1
- package/src/agents.ts +19 -10
- package/src/index.ts +5 -21
- package/src/tool-bundles.ts +84 -0
- package/src/tool-names.ts +29 -0
- package/src/window-placement.ts +31 -14
- package/src/tool-ceiling.ts +0 -125
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.
|
|
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
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
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
|
|
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>-
|
|
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-
|
|
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-
|
|
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>-
|
|
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
|
|
404
|
-
|
|
405
|
-
resumes
|
|
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
|
|
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.
|
|
977
|
-
are not checked against the parent's tools: normal Pi
|
|
978
|
-
or `session:launch` customization can provide tools in the
|
|
979
|
-
|
|
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,
|
|
997
|
-
|
|
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)
|
|
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`, `
|
|
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
|
|
1278
|
-
|
|
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/
|
|
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
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
|
|
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 {
|
|
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)
|
|
276
|
-
* only `-name` entries take those off the parent's tools.
|
|
277
|
-
* names and `-name` entries mixed,
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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: () =>
|
|
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
|
+
}
|
package/src/window-placement.ts
CHANGED
|
@@ -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
|
|
3
|
-
// opens
|
|
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" ||
|
|
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}
|
|
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
|
|
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))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|
package/src/tool-ceiling.ts
DELETED
|
@@ -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
|
-
}
|