pi-squire 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/README.md +43 -31
- package/package.json +1 -1
- package/src/agents.ts +19 -10
- package/src/index.ts +1 -17
- package/src/tool-bundles.ts +84 -0
- package/src/tool-names.ts +29 -0
- package/src/tool-ceiling.ts +0 -125
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.17.0] - 2026-11-01
|
|
10
|
+
|
|
11
|
+
### Breaking
|
|
12
|
+
|
|
13
|
+
- Nested launches and resumes no longer cap a child's tools to its direct
|
|
14
|
+
parent's. A nested child gets the tools its own agent file gives, as at top
|
|
15
|
+
level, and `session:launch` additions are no longer cut back afterwards; the
|
|
16
|
+
`allow_nested_delegation` list is the boundary. A read-only child started by
|
|
17
|
+
a writable parent now keeps tools such as `grep` that the parent lacks. The
|
|
18
|
+
"Nested tool ceiling" notices are gone. Tests that pinned the ceiling now pin
|
|
19
|
+
the new behaviour.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `toolBundles` in `squire/settings.json` names shared tool lists; an agent's
|
|
24
|
+
`tools` field uses one as `$name`, for example `tools: [$readonly, pstack_models]`.
|
|
25
|
+
An unknown bundle, a `-$name` entry or an invalid setting disables the agent
|
|
26
|
+
that uses it.
|
|
27
|
+
|
|
9
28
|
## [0.16.0] - 2026-11-01
|
|
10
29
|
|
|
11
30
|
### Breaking
|
|
@@ -979,7 +998,8 @@ author's dotfiles; the changes below are against that copy.
|
|
|
979
998
|
|
|
980
999
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
981
1000
|
|
|
982
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.
|
|
1001
|
+
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.17.0...HEAD
|
|
1002
|
+
[0.17.0]: https://github.com/gvanderclay/pi-squire/compare/v0.16.0...v0.17.0
|
|
983
1003
|
[0.16.0]: https://github.com/gvanderclay/pi-squire/compare/v0.15.0...v0.16.0
|
|
984
1004
|
[0.15.0]: https://github.com/gvanderclay/pi-squire/compare/v0.14.0...v0.15.0
|
|
985
1005
|
[0.14.0]: https://github.com/gvanderclay/pi-squire/compare/v0.13.1...v0.14.0
|
package/README.md
CHANGED
|
@@ -177,7 +177,7 @@ a path-specific warning. Unknown or unavailable names warn and cannot launch;
|
|
|
177
177
|
known entries remain usable, and later roster changes apply normally.
|
|
178
178
|
Below the limit the grant adds `delegate`,
|
|
179
179
|
`delegation_status`, `delegation_close` and `delegation_resume` even with an
|
|
180
|
-
explicit `tools` list
|
|
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,7 +351,7 @@ Only the **direct parent** may resume a child. Ancestor visibility grants no
|
|
|
354
351
|
resume authority; forked/cloned/copied history with a new Pi id grants none.
|
|
355
352
|
Return to the original parent. Identity is read from exact-id durable state,
|
|
356
353
|
written before launch; inherited launch environment cannot assign a new session
|
|
357
|
-
to the old tree. Current allowlists, depth, caps
|
|
354
|
+
to the old tree. Current allowlists, depth, caps and
|
|
358
355
|
directory/source trust still apply to resume, including its picker. A live
|
|
359
356
|
same-id Squire owner and obsolete local/shared resume chains are refused.
|
|
360
357
|
|
|
@@ -932,10 +929,10 @@ optional, and the [first-use example](#first-use) is a complete agent file:
|
|
|
932
929
|
| `display_name` | Shown instead of the name on the delegate card, in `/delegates` and in the window title (`<display_name>-<label>`). The agent is still called by its name. It may hold only ASCII letters, digits, `-` and `_`; any other value (a space, `#`, `.`, a non-ASCII letter, or an empty value) makes the file invalid, so the agent is disabled. |
|
|
933
930
|
| `model` | One model: `provider/id` or an alias such as `sonnet`, passed to the child's `--model` unchanged, so Pi resolves it to one model. A `*` pattern makes the file invalid. Absent means the parent session's current model. |
|
|
934
931
|
| `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. Absent means the parent session's level; an unknown value is dropped with a warning and the parent's level applies. Pi clamps the level to the model. |
|
|
935
|
-
| `tools` | The delegate's tools (see below): tool names for exactly those, or only `-name` entries for the parent session's current tools less those. Absent means the parent session's current tools; empty (`tools:` or `tools: []`) means no tools except the `delegateTools` setting. MCP tools follow the selection like any other. |
|
|
932
|
+
| `tools` | The delegate's tools (see below): tool names and `$bundle` entries for exactly those, or only `-name` entries for the parent session's current tools less those. Absent means the parent session's current tools; empty (`tools:` or `tools: []`) means no tools except the `delegateTools` setting. MCP tools follow the selection like any other. |
|
|
936
933
|
| `fallback` | Models to try, in order, when `model` is usage-limited (see [Usage limits](#usage-limits)). Each entry is one model, like `model`; a `*` pattern, or a YAML list entry holding a comma, makes the file invalid. A comma-separated line (`fallback: a, b`) is two entries. |
|
|
937
934
|
| `auto_exit` | `true` or `false`, default `true`; see [Auto-exit](#auto-exit). |
|
|
938
|
-
| `allow_nested_delegation` | Unreleased: `true` or a YAML list of exact child agent names; grants nested delegation below `maxDepth
|
|
935
|
+
| `allow_nested_delegation` | Unreleased: `true` or a YAML list of exact child agent names; grants nested delegation below `maxDepth` (see [Nested delegation](#nested-delegation-unreleased)). Absent, false or an empty list grants none. Malformed values disable the agent; unavailable names warn. |
|
|
939
936
|
| `prompt_mode` | `append` (the default) wraps the body in `<agent_instructions>` and `</agent_instructions>` lines; `replace` adds the body unwrapped. An empty body adds nothing either way. |
|
|
940
937
|
| `enabled` | `false` leaves the agent out. A project file with `enabled: false` hides a global agent of the same name in that project. |
|
|
941
938
|
| `locked` | `true`, or a list of `model` and `thinking`. A call to `delegate`, `/delegate` or a resume that passes a locked field does not get it: the agent's value is used (the parent's when the file sets none) and the result says which agent locked which field. Passing the agent's own value is the same as passing nothing: no notice, and its fallbacks still apply. A resume of a locked field runs on the file's value as it is now, not on what the delegate last ran on. `true` locks the fields the file sets. |
|
|
@@ -973,11 +970,10 @@ loadable by `tool_search`, whatever its exposure. Pi filters MCP tools by
|
|
|
973
970
|
such entry pi-squire adds the entry `mcp__`, which names no tool; an empty
|
|
974
971
|
selection is `--no-tools`, which removes MCP tools too. To give a delegate an
|
|
975
972
|
MCP server's tools, name them, for example `mcp__docs__*`. An extension that
|
|
976
|
-
turns tools on at run time can still add only tools the list allows.
|
|
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.
|
|
973
|
+
turns tools on at run time can still add only tools the list allows. Tool names
|
|
974
|
+
are not checked against the parent's tools, at top level or nested: normal Pi
|
|
975
|
+
extension discovery or `session:launch` customization can provide tools in the
|
|
976
|
+
child.
|
|
981
977
|
|
|
982
978
|
- No `tools` field: the delegate gets the parent session's tools as they are
|
|
983
979
|
when the launch (or resume) starts: active tools for a top-level launch, or
|
|
@@ -988,13 +984,31 @@ selection and customization, as described above; this is not filesystem isolatio
|
|
|
988
984
|
except the `delegateTools` entries. With none of those either, it starts
|
|
989
985
|
with `--no-tools`.
|
|
990
986
|
- A `tools` list of tool names: exactly those tools, plus the
|
|
991
|
-
`delegateTools` entries.
|
|
987
|
+
`delegateTools` entries. A `$name` entry stands for the tools of that
|
|
988
|
+
bundle in the `toolBundles` setting (see below).
|
|
992
989
|
- A `tools` list of only `-name` entries (`tools: -bash, -write`): the
|
|
993
990
|
parent's tools, as with no `tools` field, less those, plus the
|
|
994
991
|
`delegateTools` entries. A `-name` entry may be a `*` pattern
|
|
995
992
|
(`-mcp__docs__*`).
|
|
996
|
-
- A `+name` entry,
|
|
997
|
-
|
|
993
|
+
- A `+name` entry, a `-$name` entry, a `$name` entry naming no bundle, or
|
|
994
|
+
tool names and `-name` entries in one list, make the file invalid, and the
|
|
995
|
+
agent is disabled (see below).
|
|
996
|
+
|
|
997
|
+
`toolBundles` in `<agent dir>/squire/settings.json` names tool lists that
|
|
998
|
+
several agents share:
|
|
999
|
+
|
|
1000
|
+
```json
|
|
1001
|
+
{ "toolBundles": { "readonly": ["read", "grep", "find", "ls"] } }
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
An agent then writes `tools: [$readonly, pstack_models]`, which reads as
|
|
1005
|
+
`read, grep, find, ls, pstack_models`; a name listed twice counts once. A
|
|
1006
|
+
bundle name is letters, digits, `_` or `-`; each entry is one tool name or
|
|
1007
|
+
`*` pattern, with no comma and no leading `+`, `-` or `$`, so bundles do not
|
|
1008
|
+
nest. A bundle carries tools only, not permission rules. The setting is read
|
|
1009
|
+
with the roster. A setting that is not an object of such lists disables every
|
|
1010
|
+
agent that uses a bundle, with the reason; agents without bundles are
|
|
1011
|
+
unaffected. (`$`, unlike `@`, may start a plain YAML value.)
|
|
998
1012
|
|
|
999
1013
|
The delegation tools (`delegate`, `delegation_status`, `delegation_close`,
|
|
1000
1014
|
`delegation_resume`) are removed from ordinary tool selections. An opted-in
|
|
@@ -1015,9 +1029,7 @@ the `delegateTools` setting, in `<agent dir>/squire/settings.json` beside
|
|
|
1015
1029
|
|
|
1016
1030
|
Each entry, a tool name or `*` pattern, is added to every delegate's tools,
|
|
1017
1031
|
whether they come from its agent's `tools` or from the parent (after any
|
|
1018
|
-
`-name` entries are taken off)
|
|
1019
|
-
unchanged; nested launches/resumes resolve and cap them by the direct parent’s
|
|
1020
|
-
available tools. The setting
|
|
1032
|
+
`-name` entries are taken off), and passed to `--tools` unchanged. The setting
|
|
1021
1033
|
is empty by default, is read at each launch and resume, and a value that is
|
|
1022
1034
|
not a list of names, or an entry that holds a comma or starts with `+` or
|
|
1023
1035
|
`-`, refuses every launch with an error naming the file, before the task is sent or a window
|
|
@@ -1064,8 +1076,8 @@ add one.
|
|
|
1064
1076
|
|
|
1065
1077
|
`<agent dir>/squire/` holds the optional parent guidance file and
|
|
1066
1078
|
`settings.json` (`maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`,
|
|
1067
|
-
`windowPlacement`, `worktreeRoot`, `delegateTools`, `
|
|
1068
|
-
`delegateWidgetMaxEntries`); the
|
|
1079
|
+
`windowPlacement`, `worktreeRoot`, `delegateTools`, `toolBundles`,
|
|
1080
|
+
`delegateWidgetPlacement` and `delegateWidgetMaxEntries`); the
|
|
1069
1081
|
agents live in `<agent dir>/agents/` (see [Agents](#agents)).
|
|
1070
1082
|
`delegation_prompt.md` is Markdown, inserted unchanged as the
|
|
1071
1083
|
`squire_parent_delegation` system-prompt section before each new run, including
|
|
@@ -1274,8 +1286,8 @@ success or cancellation envelope.
|
|
|
1274
1286
|
|
|
1275
1287
|
Once the explicit operation is complete, a legitimate direct-parent resume can
|
|
1276
1288
|
supersede that session's completed fence as a **new lifetime-counted delegation**.
|
|
1277
|
-
It still passes current authority/owner, chain, depth, grants, caps
|
|
1278
|
-
|
|
1289
|
+
It still passes current authority/owner, chain, depth, grants, caps and trust
|
|
1290
|
+
checks. Failed pre-dispatch resumes leave the old fence effective.
|
|
1279
1291
|
Closed descendants remain canceled; resuming a coordinator does not revive them.
|
|
1280
1292
|
An unresolved active close cannot be bypassed by resume.
|
|
1281
1293
|
|
|
@@ -1584,7 +1596,7 @@ and it resumes as the command does.
|
|
|
1584
1596
|
receives it once its mailbox starts. If the send fails, the new window is
|
|
1585
1597
|
killed and nothing is recorded.
|
|
1586
1598
|
- Only its direct parent may resume it, preserving the original root/depth.
|
|
1587
|
-
The current parent grant, depth/cap/
|
|
1599
|
+
The current parent grant, depth/cap/trust checks are reapplied.
|
|
1588
1600
|
A chain resumes from its newest local/shared delegation; older records and
|
|
1589
1601
|
copied fork/clone history are refused, naming the owner or newest record.
|
|
1590
1602
|
- It is refused, before anything is sent or opened, unless: the delegation is a
|
|
@@ -1954,7 +1966,7 @@ Other extensions may rely on:
|
|
|
1954
1966
|
- the agent file's frontmatter keys and its locations,
|
|
1955
1967
|
`<agent dir>/agents/<name>.md` and, in a trusted project,
|
|
1956
1968
|
`<cwd>/.pi/agents/<name>.md`;
|
|
1957
|
-
- the `maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`, `delegateTools`,
|
|
1969
|
+
- the `maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`, `delegateTools`, `toolBundles`,
|
|
1958
1970
|
`windowPlacement`, `worktreeRoot`, `delegateWidgetPlacement` and
|
|
1959
1971
|
`delegateWidgetMaxEntries` settings in
|
|
1960
1972
|
`<agent dir>/squire/settings.json`;
|
package/package.json
CHANGED
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 {
|
|
@@ -1044,22 +1044,6 @@ export default function delegate(
|
|
|
1044
1044
|
for (const key of ["parent", "root", "depth", "chain"] as const)
|
|
1045
1045
|
Object.defineProperty(payload, key, { writable: false, configurable: false });
|
|
1046
1046
|
pi.events.emit(LAUNCH, payload);
|
|
1047
|
-
if (identity.current()) {
|
|
1048
|
-
const prefix = parentCommand().length;
|
|
1049
|
-
const args = payload.args.slice(prefix);
|
|
1050
|
-
enforceToolCeiling({
|
|
1051
|
-
args,
|
|
1052
|
-
available: availableTools(pi).filter(
|
|
1053
|
-
(name) => grantsNesting(start.agent, own.depth) || !DELEGATION_TOOLS.has(name),
|
|
1054
|
-
),
|
|
1055
|
-
registered: pi.getAllTools().map((tool) => tool.name),
|
|
1056
|
-
cwd,
|
|
1057
|
-
agentDir: payload.env.PI_CODING_AGENT_DIR,
|
|
1058
|
-
trusted,
|
|
1059
|
-
notices: start.notices,
|
|
1060
|
-
});
|
|
1061
|
-
payload.args.splice(prefix, payload.args.length - prefix, ...args);
|
|
1062
|
-
}
|
|
1063
1047
|
const token: Reservation = { root: own.root, id };
|
|
1064
1048
|
bindTree(token, { windowName: name, cwd, workspace });
|
|
1065
1049
|
dispatchTree(token);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Tool bundles: named tool lists in `<agent dir>/squire/settings.json`
|
|
2
|
+
// `toolBundles`, which an agent's `tools` field names as `$name`. A bundle is
|
|
3
|
+
// only shorthand for tool names; it carries no permission rules.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/** A bundle name: the same rule as an agent name. */
|
|
8
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Replace each `$name` entry with the tools of the `toolBundles` bundle it
|
|
12
|
+
* names, keeping the first of any repeated name. An unknown bundle, or any
|
|
13
|
+
* bundle while the setting is invalid, makes the file invalid.
|
|
14
|
+
*/
|
|
15
|
+
export function expandBundles(
|
|
16
|
+
path: string,
|
|
17
|
+
tools: string[],
|
|
18
|
+
bundles: Bundles,
|
|
19
|
+
): { tools: string[]; toolsExcept: string[] } | { invalid: string } {
|
|
20
|
+
const expanded: string[] = [];
|
|
21
|
+
for (const entry of tools) {
|
|
22
|
+
if (!entry.startsWith("$")) {
|
|
23
|
+
expanded.push(entry);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const names = bundles.names.get(entry.slice(1));
|
|
27
|
+
if (names === undefined)
|
|
28
|
+
return {
|
|
29
|
+
invalid:
|
|
30
|
+
bundles.problem === undefined
|
|
31
|
+
? `${path} has tools entry ${JSON.stringify(entry)}, but toolBundles in ${bundles.file} defines no bundle ${JSON.stringify(entry.slice(1))}`
|
|
32
|
+
: `${path} has tools entry ${JSON.stringify(entry)}, but ${bundles.problem}`,
|
|
33
|
+
};
|
|
34
|
+
expanded.push(...names);
|
|
35
|
+
}
|
|
36
|
+
return { tools: [...new Set(expanded)], toolsExcept: [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `toolBundles` setting: tool lists by bundle name, or why the setting cannot be used. */
|
|
40
|
+
export type Bundles = { file: string; names: ReadonlyMap<string, readonly string[]>; problem?: string };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `toolBundles` setting in `<agent dir>/squire/settings.json`: named lists
|
|
44
|
+
* of tool names or `*` patterns an agent's `tools` field names as `$name`.
|
|
45
|
+
* Empty when the file or setting is absent. A setting that is not an object of
|
|
46
|
+
* name lists, or a bundle entry that holds a comma or starts with `+`, `-` or
|
|
47
|
+
* `$` (bundles do not nest), leaves no bundles and says why.
|
|
48
|
+
*/
|
|
49
|
+
export function toolBundles(agentDir: string): Bundles {
|
|
50
|
+
const file = join(agentDir, "squire", "settings.json");
|
|
51
|
+
const none = (problem?: string): Bundles => ({ file, names: new Map(), ...(problem ? { problem } : {}) });
|
|
52
|
+
let text: string;
|
|
53
|
+
try {
|
|
54
|
+
text = readFileSync(file, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
return none();
|
|
57
|
+
}
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return none(`${file} is not valid JSON, so its toolBundles cannot be read`);
|
|
63
|
+
}
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return none();
|
|
65
|
+
const value = (parsed as Record<string, unknown>).toolBundles;
|
|
66
|
+
if (value === undefined) return none();
|
|
67
|
+
const invalid = (why: string): Bundles => none(`toolBundles in ${file} is invalid: ${why}`);
|
|
68
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
69
|
+
return invalid("it must be an object of bundle names to lists of tool names");
|
|
70
|
+
const names = new Map<string, string[]>();
|
|
71
|
+
for (const [name, list] of Object.entries(value)) {
|
|
72
|
+
if (!NAME.test(name)) return invalid(`bundle name ${JSON.stringify(name)} is not letters, digits, _ or -`);
|
|
73
|
+
if (!Array.isArray(list) || !list.every((entry) => typeof entry === "string" && entry.trim() !== ""))
|
|
74
|
+
return invalid(`bundle ${JSON.stringify(name)} must be a list of tool names or patterns`);
|
|
75
|
+
const tools = list.map((entry: string) => entry.trim());
|
|
76
|
+
const bad = tools.find((tool) => tool.includes(",") || /^[+\-$]/.test(tool));
|
|
77
|
+
if (bad !== undefined)
|
|
78
|
+
return invalid(
|
|
79
|
+
`bundle ${JSON.stringify(name)} entry ${JSON.stringify(bad)} must be one tool name or pattern, with no comma and no leading +, - or $`,
|
|
80
|
+
);
|
|
81
|
+
names.set(name, tools);
|
|
82
|
+
}
|
|
83
|
+
return { file, names };
|
|
84
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Tool-name helpers shared by the roster and launch code.
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/** Match Pi's tool-list glob: only * has special meaning. */
|
|
5
|
+
export function toolMatches(pattern: string, name: string): boolean {
|
|
6
|
+
const source = pattern
|
|
7
|
+
.split("*")
|
|
8
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
9
|
+
.join(".*");
|
|
10
|
+
return new RegExp(`^${source}$`).test(name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The operations this session can reach: active tools plus codemode and
|
|
15
|
+
* deferred ones, never hidden ones. A nested delegate with no `tools` field
|
|
16
|
+
* inherits these. Includes active model-only operations, though
|
|
17
|
+
* ctx.executeTool cannot call those.
|
|
18
|
+
*/
|
|
19
|
+
export function availableTools(pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">): string[] {
|
|
20
|
+
const active = new Set(pi.getActiveTools());
|
|
21
|
+
return pi
|
|
22
|
+
.getAllTools()
|
|
23
|
+
.filter(
|
|
24
|
+
(tool) =>
|
|
25
|
+
tool.exposure !== "hidden" &&
|
|
26
|
+
(active.has(tool.name) || tool.exposure === "codemode" || tool.exposure === "deferred"),
|
|
27
|
+
)
|
|
28
|
+
.map((tool) => tool.name);
|
|
29
|
+
}
|
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
|
-
}
|