pi-squire 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -1
- package/README.md +23 -3
- package/package.json +1 -1
- package/src/agents.ts +43 -3
- package/src/child.ts +4 -2
- package/src/index.ts +20 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.2] - 2026-10-09
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Agent frontmatter `extensions` lists extension paths loaded only by that
|
|
14
|
+
agent's delegate, each passed to its Pi as `-e` on top of its normal
|
|
15
|
+
extensions. `~` expands and relative paths resolve against the `AGENT.md`'s
|
|
16
|
+
directory; a path that does not exist leaves the agent out with a warning.
|
|
17
|
+
- Agent frontmatter `tools` lists the delegate's visible tools, passed as
|
|
18
|
+
`--tools`. Without discovery tools such as `codemode` or `tool_search`, only
|
|
19
|
+
listed tools are visible; Pi can keep unlisted MCP tools registered unless
|
|
20
|
+
the list includes an `mcp__` entry, and discovery tools can expose them.
|
|
21
|
+
Names are not checked against the parent's tools, and `exclude-tools` still
|
|
22
|
+
applies alongside.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- The `delegate` tool treats `null` or the string `"null"` in `model`,
|
|
27
|
+
`thinking` or `label` as unset, so the agent's defaults apply. Before, a
|
|
28
|
+
`null` model crashed the call and `"null"` was refused as not provider/id.
|
|
29
|
+
|
|
30
|
+
## [0.3.1] - 2026-10-03
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- With auto-exit on, a delegate whose run ends on a usage-limit error now
|
|
35
|
+
closes its window, as after a normal completion. The parent still gets the
|
|
36
|
+
`failed` reply and the session file stays. Other failures still leave the
|
|
37
|
+
window open. `test/harness.test.ts` now fails its run with a `400` instead
|
|
38
|
+
of a `429`, since a `429` is a usage limit and now exits.
|
|
39
|
+
|
|
9
40
|
## [0.3.0] - 2026-10-03
|
|
10
41
|
|
|
11
42
|
### Added
|
|
@@ -138,7 +169,9 @@ author's dotfiles; the changes below are against that copy.
|
|
|
138
169
|
|
|
139
170
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
140
171
|
|
|
141
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.3.
|
|
172
|
+
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.3.2...HEAD
|
|
173
|
+
[0.3.2]: https://github.com/gvanderclay/pi-squire/compare/v0.3.1...v0.3.2
|
|
174
|
+
[0.3.1]: https://github.com/gvanderclay/pi-squire/compare/v0.3.0...v0.3.1
|
|
142
175
|
[0.3.0]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...v0.3.0
|
|
143
176
|
[0.2.2]: https://github.com/gvanderclay/pi-squire/compare/v0.2.1...v0.2.2
|
|
144
177
|
[0.2.1]: https://github.com/gvanderclay/pi-squire/compare/v0.2.0...v0.2.1
|
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ The roster is read at call time from `<agent dir>/agents/<name>/AGENT.md`, one
|
|
|
116
116
|
directory per agent, so a new agent is usable without a reload. Frontmatter
|
|
117
117
|
carries `description`, `model` (`provider/id`) and `thinking`, and optionally
|
|
118
118
|
`auto-exit` (`true` or `false`, default `true`; see [Auto-exit](#auto-exit)),
|
|
119
|
-
`exclude-tools` and `fallback`. The body is the delegate's system prompt,
|
|
119
|
+
`exclude-tools`, `tools`, `extensions` and `fallback`. The body is the delegate's system prompt,
|
|
120
120
|
appended to Pi's own prompt; it reaches the child as a path to a private file
|
|
121
121
|
under the system temporary folder, so the text stays off the command line. The
|
|
122
122
|
[first-use example](#first-use) is a complete `AGENT.md`.
|
|
@@ -133,6 +133,23 @@ delegate's result reaches the parent through the `message:*` provider's reply
|
|
|
133
133
|
when its run settles, not through a tool, so excluding tools cannot stop it
|
|
134
134
|
answering.
|
|
135
135
|
|
|
136
|
+
`tools` lists tools for the delegate as a comma-separated or YAML list
|
|
137
|
+
(`tools: device_run`). It reaches the child as `--tools device_run`, which
|
|
138
|
+
covers extension tools too. Without discovery tools such as `codemode` or
|
|
139
|
+
`tool_search`, only the listed tools are visible. Pi can keep unlisted MCP
|
|
140
|
+
tools registered unless the list includes an `mcp__` entry; discovery tools can
|
|
141
|
+
also expose them. Names are not checked against the parent's tools, since a
|
|
142
|
+
tool from one of the agent's own `extensions` exists only in the child. Empty or
|
|
143
|
+
absent passes no flag. `exclude-tools` still applies alongside.
|
|
144
|
+
|
|
145
|
+
`extensions` lists extension paths only this agent's delegate loads, as a
|
|
146
|
+
comma-separated or YAML list. Each reaches the child as a repeated
|
|
147
|
+
`-e <absolute path>`, on top of its normal extensions, so pi-squire and the
|
|
148
|
+
`message:*` provider still load and the result still reaches the parent. `~`
|
|
149
|
+
and `~/` expand to the home directory and a relative path resolves against the
|
|
150
|
+
directory holding that `AGENT.md`. A path that does not exist when the roster
|
|
151
|
+
is read leaves the agent out, with a warning.
|
|
152
|
+
|
|
136
153
|
`fallback` lists models to try, in order, when the agent's `model` is
|
|
137
154
|
usage-limited (see [Usage limits](#usage-limits)), as a comma-separated list
|
|
138
155
|
or a YAML list of `provider/id`. An entry that is not `provider/id` is dropped
|
|
@@ -150,7 +167,8 @@ An `AGENT.md` that cannot be used is left out of the roster and named in a
|
|
|
150
167
|
warning. That covers missing fields, a model that is not `provider/id`, an
|
|
151
168
|
unknown thinking level, an `auto-exit` that is not `true` or `false`, an
|
|
152
169
|
`exclude-tools` that is not a list of names or names a tool the session does
|
|
153
|
-
not have,
|
|
170
|
+
not have, a `tools` or `extensions` that is not a list, an extension path that
|
|
171
|
+
does not exist, and an empty body. With no agents at all, `/delegate` says how to
|
|
154
172
|
add one.
|
|
155
173
|
|
|
156
174
|
## Configuration
|
|
@@ -353,7 +371,9 @@ The parent resolves each delegation's auto-exit from the call
|
|
|
353
371
|
down (`ctx.shutdown()`) on the next event-loop turn, after the settle
|
|
354
372
|
reply has been sent, so its tmux window closes. The session file stays, and
|
|
355
373
|
`pi --session <path>` reopens it.
|
|
356
|
-
- A run that ended stopped or failed
|
|
374
|
+
- A run that ended stopped, or failed on anything but a usage limit, never
|
|
375
|
+
exits the delegate. A usage-limit failure is final until the quota resets,
|
|
376
|
+
so it exits like a normal completion; the parent still gets the failed reply.
|
|
357
377
|
- The user taking over turns it off for the rest of the session, with a
|
|
358
378
|
notice: typed or RPC input, or a run the user stopped (Esc).
|
|
359
379
|
- `/auto-exit` inside the delegate turns it on again for the next normal
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
// call time so a new agent is usable without a reload. Frontmatter carries
|
|
3
3
|
// `description`, `model` and `thinking`, and optionally `auto-exit` (default
|
|
4
4
|
// true) and `exclude-tools` (tools the delegate goes without, passed to its Pi
|
|
5
|
-
// as `--exclude-tools`; each must be a tool the parent session has)
|
|
5
|
+
// as `--exclude-tools`; each must be a tool the parent session has), `tools`
|
|
6
|
+
// (the delegate's complete tool allowlist, passed as `--tools`, not checked
|
|
7
|
+
// against the parent) and `extensions` (extra extension paths for the
|
|
8
|
+
// delegate only, each passed as `-e`; `~` expands, relative paths resolve
|
|
9
|
+
// against the AGENT.md's directory, and a missing path leaves the agent out); the body
|
|
6
10
|
// is the delegate's system prompt. A file that is missing or malformed is left out, with a warning.
|
|
7
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
8
|
-
import {
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
9
14
|
|
|
10
15
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
11
16
|
|
|
@@ -33,6 +38,10 @@ export type Agent = {
|
|
|
33
38
|
autoExit: boolean;
|
|
34
39
|
/** Tools the delegate goes without, from `exclude-tools`; empty means all of them. */
|
|
35
40
|
excludeTools: string[];
|
|
41
|
+
/** The delegate's complete tool allowlist, from `tools`; empty means no `--tools` flag. */
|
|
42
|
+
tools: string[];
|
|
43
|
+
/** Absolute paths of extra extensions the delegate loads, from `extensions`; empty when absent. */
|
|
44
|
+
extensions: string[];
|
|
36
45
|
/** Models to try, in order, when `model` is usage-limited; from `fallback`, empty when absent. */
|
|
37
46
|
fallback: string[];
|
|
38
47
|
/** The body of `AGENT.md`: the delegate's system prompt. */
|
|
@@ -96,6 +105,8 @@ function parseAgent(
|
|
|
96
105
|
return {
|
|
97
106
|
warning: `${path} has exclude-tools ${JSON.stringify(unknownTool)}, not a tool this session has; skipped`,
|
|
98
107
|
};
|
|
108
|
+
const launch = launchFields(path, frontmatter);
|
|
109
|
+
if ("warning" in launch) return launch;
|
|
99
110
|
// A bad fallback entry is dropped with a note; it never costs the agent.
|
|
100
111
|
const notes: string[] = [];
|
|
101
112
|
const listedFallback = toolList(frontmatter.fallback);
|
|
@@ -115,12 +126,41 @@ function parseAgent(
|
|
|
115
126
|
thinking: thinking as Thinking,
|
|
116
127
|
autoExit: autoExit ?? true,
|
|
117
128
|
excludeTools,
|
|
129
|
+
...launch,
|
|
118
130
|
fallback,
|
|
119
131
|
prompt: body,
|
|
120
132
|
},
|
|
121
133
|
};
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
/** The `tools` and `extensions` fields, or the warning that leaves the agent out. */
|
|
137
|
+
function launchFields(
|
|
138
|
+
path: string,
|
|
139
|
+
frontmatter: Record<string, unknown>,
|
|
140
|
+
): { tools: string[]; extensions: string[] } | { warning: string } {
|
|
141
|
+
const tools = toolList(frontmatter.tools);
|
|
142
|
+
if (tools === null)
|
|
143
|
+
return {
|
|
144
|
+
warning: `${path} has tools ${JSON.stringify(frontmatter.tools)}, not a comma-separated list of tool names; skipped`,
|
|
145
|
+
};
|
|
146
|
+
const paths = toolList(frontmatter.extensions);
|
|
147
|
+
if (paths === null)
|
|
148
|
+
return {
|
|
149
|
+
warning: `${path} has extensions ${JSON.stringify(frontmatter.extensions)}, not a comma-separated list of paths; skipped`,
|
|
150
|
+
};
|
|
151
|
+
const extensions = (paths ?? []).map((entry) => resolveExtension(entry, dirname(path)));
|
|
152
|
+
const absent = extensions.find((entry) => !existsSync(entry));
|
|
153
|
+
if (absent !== undefined) return { warning: `${path} has extension ${absent}, which does not exist; skipped` };
|
|
154
|
+
return { tools: tools ?? [], extensions };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** An extension path as an absolute one: `~` is the home directory, a relative path is against the AGENT.md's directory. */
|
|
158
|
+
function resolveExtension(entry: string, dir: string): string {
|
|
159
|
+
if (entry === "~") return homedir();
|
|
160
|
+
if (entry.startsWith("~/")) return join(homedir(), entry.slice(2));
|
|
161
|
+
return isAbsolute(entry) ? entry : resolve(dir, entry);
|
|
162
|
+
}
|
|
163
|
+
|
|
124
164
|
/** A list of names (tools or models), comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
|
|
125
165
|
function toolList(value: unknown): string[] | undefined | null {
|
|
126
166
|
if (value === undefined || value === null) return undefined;
|
package/src/child.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// ponytail: drop the takeover once #5581 lands and the minimum Pi has it.
|
|
18
18
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
|
|
20
|
-
import { type Response, recordLimit } from "./limits.ts";
|
|
20
|
+
import { classify, type Response, recordLimit } from "./limits.ts";
|
|
21
21
|
|
|
22
22
|
/** The launch variable that carries the parent's resolved setting. */
|
|
23
23
|
export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
|
|
@@ -99,7 +99,9 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
99
99
|
const stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
|
|
100
100
|
if (stopped) disarm(ctx);
|
|
101
101
|
if (!stopped && last?.stopReason === "error") recordUsageLimit(last, ctx, response);
|
|
102
|
-
|
|
102
|
+
// A usage limit is final until the quota resets, so it ends the work like a completion.
|
|
103
|
+
const limited = typeof last?.errorMessage === "string" && classify(last.errorMessage) !== undefined;
|
|
104
|
+
completed = !stopped && (last?.stopReason !== "error" || limited);
|
|
103
105
|
});
|
|
104
106
|
|
|
105
107
|
pi.on("agent_settled", async (_event, ctx) => {
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
// call) the delegate closes its own window after a normal completion; the
|
|
18
18
|
// delegate's side lives in `child.ts`. With it off the window stays open.
|
|
19
19
|
// An agent's `exclude-tools` goes to the child as `--exclude-tools`; a name the
|
|
20
|
-
// session has no tool for leaves the agent out of the roster.
|
|
20
|
+
// session has no tool for leaves the agent out of the roster. Its `tools` goes
|
|
21
|
+
// as `--tools` and each of its `extensions` as `-e`, both for the child alone.
|
|
21
22
|
//
|
|
22
23
|
// The model gets three tools. `delegate` takes agent, task and optional model
|
|
23
24
|
// and thinking, validates them the way the command does, and starts the
|
|
@@ -308,7 +309,7 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
308
309
|
"agents",
|
|
309
310
|
"<name>",
|
|
310
311
|
"AGENT.md",
|
|
311
|
-
)} with frontmatter description, model and thinking and the prompt as its body; /delegate explains the format too.`;
|
|
312
|
+
)} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback) and the prompt as its body; /delegate explains the format too.`;
|
|
312
313
|
}
|
|
313
314
|
return [
|
|
314
315
|
intro,
|
|
@@ -318,7 +319,7 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
318
319
|
(agent) =>
|
|
319
320
|
`- ${agent.name} — ${agent.description} (default ${[agent.model, ...agent.fallback.map((model) => `fallback ${model}`)].join(", ")}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
|
|
320
321
|
agent.excludeTools.length > 0 ? `, no ${agent.excludeTools.join("/")}` : ""
|
|
321
|
-
})`,
|
|
322
|
+
}${agent.tools.length > 0 ? `, tools: ${agent.tools.join("/")}` : ""})`,
|
|
322
323
|
),
|
|
323
324
|
"",
|
|
324
325
|
"`model` and `thinking` override the agent's defaults for this call; an unknown value is refused with close matches. `label` names the delegate's window and session; give one that says what the task is. `delegation_status` reports a delegation, and `delegation_close` ends one.",
|
|
@@ -387,7 +388,7 @@ export default function delegate(
|
|
|
387
388
|
"agents",
|
|
388
389
|
"<name>",
|
|
389
390
|
"AGENT.md",
|
|
390
|
-
)} with frontmatter description, model and thinking, and the prompt as its body`,
|
|
391
|
+
)} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback), and the prompt as its body`,
|
|
391
392
|
);
|
|
392
393
|
}
|
|
393
394
|
const agent = roster.agents.find((candidate) => candidate.name === request.agent);
|
|
@@ -447,6 +448,8 @@ export default function delegate(
|
|
|
447
448
|
start.model,
|
|
448
449
|
"--thinking",
|
|
449
450
|
start.thinking,
|
|
451
|
+
...start.agent.extensions.flatMap((extension) => ["-e", extension]),
|
|
452
|
+
...(start.agent.tools.length > 0 ? ["--tools", start.agent.tools.join(",")] : []),
|
|
450
453
|
...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
|
|
451
454
|
"--append-system-prompt",
|
|
452
455
|
prompt,
|
|
@@ -536,18 +539,27 @@ export default function delegate(
|
|
|
536
539
|
}),
|
|
537
540
|
async execute(
|
|
538
541
|
_toolCallId: string,
|
|
539
|
-
params: {
|
|
542
|
+
params: {
|
|
543
|
+
agent: string;
|
|
544
|
+
task: string;
|
|
545
|
+
model?: string | null;
|
|
546
|
+
thinking?: string | null;
|
|
547
|
+
label?: string | null;
|
|
548
|
+
auto_exit?: boolean | null;
|
|
549
|
+
},
|
|
540
550
|
_signal: unknown,
|
|
541
551
|
_onUpdate: unknown,
|
|
542
552
|
ctx: ExtensionContext,
|
|
543
553
|
) {
|
|
544
554
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
555
|
+
// Some models fill every optional field with null or the string "null"; both mean unset.
|
|
556
|
+
const given = (value: string | null | undefined) => (value == null || value === "null" ? undefined : value);
|
|
545
557
|
const start = await validate(
|
|
546
558
|
{
|
|
547
559
|
agent: params.agent,
|
|
548
|
-
model: params.model,
|
|
549
|
-
thinking: params.thinking,
|
|
550
|
-
label: params.label,
|
|
560
|
+
model: given(params.model),
|
|
561
|
+
thinking: given(params.thinking),
|
|
562
|
+
label: given(params.label),
|
|
551
563
|
autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
|
|
552
564
|
task: params.task ?? "",
|
|
553
565
|
},
|