pi-squire 0.3.1 → 0.4.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 +55 -1
- package/README.md +35 -6
- package/package.json +3 -1
- package/src/agents.ts +43 -3
- package/src/child.ts +58 -9
- package/src/index.ts +40 -17
- package/src/limits.ts +1 -1
- package/src/results.ts +28 -5
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,58 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- A delegate whose run ends on a usage limit now switches to the agent's next
|
|
14
|
+
fallback that has credentials and is not marked, and continues the task in
|
|
15
|
+
the same session instead of failing. The parent passes the candidates after
|
|
16
|
+
the launched model as `PI_DELEGATE_FALLBACK`; an explicit `model` passes
|
|
17
|
+
none. A `done` result from such a delegate carries the `Usage limit:` line,
|
|
18
|
+
saying it finished on a fallback.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- A delegate message in the chat now shows collapsed, as its first two
|
|
23
|
+
lines, and expands with the tool-output toggle (Ctrl+O), in step with tool
|
|
24
|
+
output. The model still gets the whole message.
|
|
25
|
+
- With auto-exit on, a delegate whose run fails on any provider error now
|
|
26
|
+
closes its window, as after a normal completion; before, only a usage-limit
|
|
27
|
+
failure did. The parent still gets the `failed` reply and the session file
|
|
28
|
+
stays. Only a run the user stopped keeps the window open.
|
|
29
|
+
`test/auto-exit.test.ts` and `test/harness.test.ts` change with it.
|
|
30
|
+
- Refusing an explicit `model` that is usage-limited now names the agent's
|
|
31
|
+
models that are still open, so the caller can omit `model` and let the
|
|
32
|
+
fallbacks apply.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- ChatGPT's "The ChatGPT user has reached their Subscription Sharing usage
|
|
37
|
+
limit" error now counts as a usage limit, so it records a mark and later
|
|
38
|
+
launches skip the provider. Before, it recorded nothing.
|
|
39
|
+
|
|
40
|
+
## [0.3.2] - 2026-10-09
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Agent frontmatter `extensions` lists extension paths loaded only by that
|
|
45
|
+
agent's delegate, each passed to its Pi as `-e` on top of its normal
|
|
46
|
+
extensions. `~` expands and relative paths resolve against the `AGENT.md`'s
|
|
47
|
+
directory; a path that does not exist leaves the agent out with a warning.
|
|
48
|
+
- Agent frontmatter `tools` lists the delegate's visible tools, passed as
|
|
49
|
+
`--tools`. Without discovery tools such as `codemode` or `tool_search`, only
|
|
50
|
+
listed tools are visible; Pi can keep unlisted MCP tools registered unless
|
|
51
|
+
the list includes an `mcp__` entry, and discovery tools can expose them.
|
|
52
|
+
Names are not checked against the parent's tools, and `exclude-tools` still
|
|
53
|
+
applies alongside.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- The `delegate` tool treats `null` or the string `"null"` in `model`,
|
|
58
|
+
`thinking` or `label` as unset, so the agent's defaults apply. Before, a
|
|
59
|
+
`null` model crashed the call and `"null"` was refused as not provider/id.
|
|
60
|
+
|
|
9
61
|
## [0.3.1] - 2026-10-03
|
|
10
62
|
|
|
11
63
|
### Changed
|
|
@@ -148,7 +200,9 @@ author's dotfiles; the changes below are against that copy.
|
|
|
148
200
|
|
|
149
201
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
150
202
|
|
|
151
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.
|
|
203
|
+
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.4.0...HEAD
|
|
204
|
+
[0.4.0]: https://github.com/gvanderclay/pi-squire/compare/v0.3.2...v0.4.0
|
|
205
|
+
[0.3.2]: https://github.com/gvanderclay/pi-squire/compare/v0.3.1...v0.3.2
|
|
152
206
|
[0.3.1]: https://github.com/gvanderclay/pi-squire/compare/v0.3.0...v0.3.1
|
|
153
207
|
[0.3.0]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...v0.3.0
|
|
154
208
|
[0.2.2]: https://github.com/gvanderclay/pi-squire/compare/v0.2.1...v0.2.2
|
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
|
|
@@ -272,7 +290,9 @@ A mark clears at the reset time the error states ("try again in 30 minutes",
|
|
|
272
290
|
hours. When the failed response's headers state a reset (`retry-after`,
|
|
273
291
|
`anthropic-ratelimit-*-reset`, `x-ratelimit-reset-requests` or `-tokens`),
|
|
274
292
|
that time wins over the error text. Until then `delegate` and `/delegate`
|
|
275
|
-
refuse a marked model passed as `model` / `--model`,
|
|
293
|
+
refuse a marked model passed as `model` / `--model`, say when it clears, and
|
|
294
|
+
name the agent's own models that are not marked, so the caller can omit the
|
|
295
|
+
model instead.
|
|
276
296
|
|
|
277
297
|
Without an explicit model, the delegation launches on the agent's model, or
|
|
278
298
|
else on the first of its `fallback` models that has credentials and is not
|
|
@@ -282,6 +302,14 @@ the result details list the skipped models under `skipped`. When every
|
|
|
282
302
|
candidate is marked or unusable, the call is refused with one line per
|
|
283
303
|
candidate and why it was skipped.
|
|
284
304
|
|
|
305
|
+
The launch also passes the candidates after the chosen one to the delegate as
|
|
306
|
+
`PI_DELEGATE_FALLBACK` (none for an explicit model). When a delegate's run
|
|
307
|
+
ends on a usage limit, it switches its session to the first of them that has
|
|
308
|
+
credentials and is not marked, adds a message saying so, and continues the
|
|
309
|
+
task there, so work done so far is kept. Each fallback is tried once; when
|
|
310
|
+
none is left, the run fails as before. A `done` result from such a delegate
|
|
311
|
+
carries the `Usage limit:` line too, saying it finished on a fallback.
|
|
312
|
+
|
|
285
313
|
Before a launch, pi-squire also reads the OpenCode Go quota for a paid
|
|
286
314
|
`opencode-go` candidate (`GET https://opencode.ai/zen/go/v1/usage`, with the
|
|
287
315
|
provider's API key). A window at 100% or more, or one the provider reports as
|
|
@@ -353,9 +381,10 @@ The parent resolves each delegation's auto-exit from the call
|
|
|
353
381
|
down (`ctx.shutdown()`) on the next event-loop turn, after the settle
|
|
354
382
|
reply has been sent, so its tmux window closes. The session file stays, and
|
|
355
383
|
`pi --session <path>` reopens it.
|
|
356
|
-
- A run
|
|
357
|
-
exits
|
|
358
|
-
|
|
384
|
+
- A failed run, such as one ending on a usage limit or another provider
|
|
385
|
+
error, exits like a normal completion; the parent still gets the failed
|
|
386
|
+
reply and the session file stays. Only a run the user stopped never exits
|
|
387
|
+
the delegate.
|
|
359
388
|
- The user taking over turns it off for the rest of the session, with a
|
|
360
389
|
notice: typed or RPC input, or a run the user stopped (Esc).
|
|
361
390
|
- `/auto-exit` inside the delegate turns it on again for the next normal
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-squire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -45,11 +45,13 @@
|
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
48
49
|
"typebox": "*"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@biomejs/biome": "2.5.14",
|
|
52
53
|
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
54
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
53
55
|
"@types/node": "26.6.3",
|
|
54
56
|
"typebox": "1.3.27",
|
|
55
57
|
"typescript": "7.0.2"
|
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
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The parent resolves the setting and passes it as `PI_DELEGATE_AUTO_EXIT`
|
|
4
4
|
// (`1` on, `0` off). While it is on, the delegate shuts Pi down once a run
|
|
5
|
-
// settles
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// settles, so its tmux window closes; the settle reply has been sent by
|
|
6
|
+
// then, since the shutdown waits for the next event-loop turn. A failed run
|
|
7
|
+
// exits too (the parent gets the error and the session file stays); only a
|
|
8
|
+
// run the user stopped never exits. A run that ends on a usage limit first
|
|
9
|
+
// moves to the agent's next fallback, from `PI_DELEGATE_FALLBACK`, and
|
|
10
|
+
// continues there; it exits only when no fallback is left. The user
|
|
8
11
|
// taking over — typing here, or stopping a run — turns it off for the rest of
|
|
9
12
|
// the session, with a notice; `/auto-exit` turns it back on. Modelled on
|
|
10
13
|
// edxeth/pi-subagents' `auto-exit` (README "Child lifecycle", `cf6dbf4`).
|
|
@@ -17,10 +20,12 @@
|
|
|
17
20
|
// ponytail: drop the takeover once #5581 lands and the minimum Pi has it.
|
|
18
21
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
22
|
|
|
20
|
-
import { classify, type Response, recordLimit } from "./limits.ts";
|
|
23
|
+
import { activeMark, classify, type Response, recordLimit } from "./limits.ts";
|
|
21
24
|
|
|
22
25
|
/** The launch variable that carries the parent's resolved setting. */
|
|
23
26
|
export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
|
|
27
|
+
/** The launch variable listing, comma-separated, the agent's models after the one launched. */
|
|
28
|
+
export const FALLBACK_ENV = "PI_DELEGATE_FALLBACK";
|
|
24
29
|
|
|
25
30
|
const OFF_NOTICE =
|
|
26
31
|
"delegate: auto-exit is off for this session because you took over here; the window stays open. /auto-exit turns it back on.";
|
|
@@ -47,7 +52,20 @@ function recordUsageLimit(last: Message, ctx: ExtensionContext, response?: Respo
|
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
/** Switch to the first of `fallback` that is usable and not marked, taking each off the list; the one switched to, if any. */
|
|
56
|
+
async function switchModel(pi: ExtensionAPI, ctx: ExtensionContext, fallback: string[]): Promise<string | undefined> {
|
|
57
|
+
for (let next = fallback.shift(); next !== undefined; next = fallback.shift()) {
|
|
58
|
+
const slash = next.indexOf("/");
|
|
59
|
+
const model = ctx.modelRegistry.find(next.slice(0, slash), next.slice(slash + 1));
|
|
60
|
+
if (model === undefined || !ctx.modelRegistry.hasConfiguredAuth(model)) continue;
|
|
61
|
+
if (activeMark(ctx.modelRegistry, model.provider, model.id) !== undefined) continue;
|
|
62
|
+
if (await pi.setModel(model)) return next;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Take over the parent's task request and send it as a user prompt. */
|
|
68
|
+
function registerTakeover(pi: ExtensionAPI, parent: string): void {
|
|
51
69
|
let current: ExtensionContext | undefined;
|
|
52
70
|
pi.on("session_start", async (_event, ctx) => {
|
|
53
71
|
current = ctx;
|
|
@@ -62,9 +80,36 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
62
80
|
current?.ui.notify(`delegate: could not start the task: ${(err as Error).message}`, "error"),
|
|
63
81
|
);
|
|
64
82
|
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A usage limit moves the run to the next fallback that is usable and not
|
|
87
|
+
* marked, and continues it in this session; each fallback is tried once.
|
|
88
|
+
* Returns the setter for the model whose usage limit ended the last run.
|
|
89
|
+
*/
|
|
90
|
+
function registerFallback(pi: ExtensionAPI): (model: string | undefined) => void {
|
|
91
|
+
/** The models still to move to on a usage limit, in order. */
|
|
92
|
+
const fallback = (process.env[FALLBACK_ENV] ?? "").split(",").filter((model) => model.includes("/"));
|
|
93
|
+
let limited: string | undefined;
|
|
94
|
+
pi.on("agent_before_settle", async (_event, ctx) => {
|
|
95
|
+
const from = limited;
|
|
96
|
+
limited = undefined;
|
|
97
|
+
const next = from === undefined ? undefined : await switchModel(pi, ctx, fallback);
|
|
98
|
+
if (next === undefined) return;
|
|
99
|
+
const content = `[delegate] ${from} hit a usage limit, so this session switched to ${next}. Carry on with the task from where it stopped.`;
|
|
100
|
+
return { continue: true, entries: [{ type: "custom_message", customType: "delegate", content, display: true }] };
|
|
101
|
+
});
|
|
102
|
+
return (model) => {
|
|
103
|
+
limited = model;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
108
|
+
registerTakeover(pi, parent);
|
|
109
|
+
const setLimited = registerFallback(pi);
|
|
65
110
|
|
|
66
111
|
let armed = process.env[AUTO_EXIT_ENV] === "1";
|
|
67
|
-
/** Whether the run that just ended finished
|
|
112
|
+
/** Whether the run that just ended finished without the user stopping it. */
|
|
68
113
|
let completed = false;
|
|
69
114
|
|
|
70
115
|
function disarm(ctx: ExtensionContext): void {
|
|
@@ -99,9 +144,13 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
99
144
|
const stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
|
|
100
145
|
if (stopped) disarm(ctx);
|
|
101
146
|
if (!stopped && last?.stopReason === "error") recordUsageLimit(last, ctx, response);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
147
|
+
setLimited(
|
|
148
|
+
!stopped && typeof last?.errorMessage === "string" && classify(last.errorMessage) !== undefined
|
|
149
|
+
? `${String(last.provider)}/${String(last.model)}`
|
|
150
|
+
: undefined,
|
|
151
|
+
);
|
|
152
|
+
// A failure ends the work like a completion: the parent gets the error, the session file stays.
|
|
153
|
+
completed = !stopped;
|
|
105
154
|
});
|
|
106
155
|
|
|
107
156
|
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
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
import { Type } from "typebox";
|
|
47
48
|
|
|
48
49
|
import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
|
|
49
|
-
import { AUTO_EXIT_ENV, registerChild } from "./child.ts";
|
|
50
|
+
import { AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
|
|
50
51
|
import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
|
|
51
52
|
import { createResults, delegateName } from "./results.ts";
|
|
52
53
|
import { createTmuxClient, type TmuxClient } from "./tmux.ts";
|
|
@@ -113,6 +114,8 @@ type Start = {
|
|
|
113
114
|
skipped: Skipped[];
|
|
114
115
|
/** Lines for the parent, such as a quota reading that failed. */
|
|
115
116
|
notices: string[];
|
|
117
|
+
/** The candidates after `model`, for the delegate to move to on a usage limit; empty for an explicit model. */
|
|
118
|
+
rest: string[];
|
|
116
119
|
thinking: string;
|
|
117
120
|
label?: string;
|
|
118
121
|
autoExit: boolean;
|
|
@@ -237,7 +240,7 @@ async function chooseModel(
|
|
|
237
240
|
agent: Agent,
|
|
238
241
|
models: ModelRegistry,
|
|
239
242
|
proactive: Proactive,
|
|
240
|
-
): Promise<{ model: string; skipped: Skipped[]; notices: string[] }> {
|
|
243
|
+
): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
|
|
241
244
|
const candidates = explicit !== undefined ? [explicit] : [agent.model, ...agent.fallback];
|
|
242
245
|
const skipped: Skipped[] = [];
|
|
243
246
|
const notices: string[] = [];
|
|
@@ -261,7 +264,7 @@ async function chooseModel(
|
|
|
261
264
|
if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
|
|
262
265
|
mark = activeMark(models, provider, id) ?? checked.mark;
|
|
263
266
|
}
|
|
264
|
-
if (mark === undefined) return { model, skipped, notices };
|
|
267
|
+
if (mark === undefined) return { model, skipped, notices, rest: candidates.slice(candidates.indexOf(model) + 1) };
|
|
265
268
|
const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
|
|
266
269
|
const until = new Date(mark.clearsAt).toISOString();
|
|
267
270
|
skipped.push({
|
|
@@ -272,11 +275,18 @@ async function chooseModel(
|
|
|
272
275
|
}
|
|
273
276
|
if (skipped.length === 1) {
|
|
274
277
|
const [only] = skipped;
|
|
275
|
-
throw new Error(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
if (!only.why.startsWith("usage-limited")) throw new Error(only.reason);
|
|
279
|
+
// An explicit model is never substituted, but the refusal names the agent's models still open.
|
|
280
|
+
const open = [agent.model, ...agent.fallback].filter((model) => {
|
|
281
|
+
if (model === only.model || modelProblem(model, models) !== undefined) return false;
|
|
282
|
+
const slash = model.indexOf("/");
|
|
283
|
+
return activeMark(models, model.slice(0, slash), model.slice(slash + 1)) === undefined;
|
|
284
|
+
});
|
|
285
|
+
const hint =
|
|
286
|
+
explicit !== undefined && open.length > 0
|
|
287
|
+
? ` Omit model to launch ${agent.name} on ${open[0]}${open.length > 1 ? ` (also open: ${open.slice(1).join(", ")})` : ""}, or clear marks with /delegate-clear.`
|
|
288
|
+
: " Clear marks with /delegate-clear.";
|
|
289
|
+
throw new Error(`${only.model} is ${only.reason}.${hint}`);
|
|
280
290
|
}
|
|
281
291
|
throw new Error(
|
|
282
292
|
`no model is available for agent ${agent.name}:\n${skipped.map((item) => `- ${item.model}: ${item.reason}`).join("\n")}\nClear marks with /delegate-clear.`,
|
|
@@ -308,7 +318,7 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
308
318
|
"agents",
|
|
309
319
|
"<name>",
|
|
310
320
|
"AGENT.md",
|
|
311
|
-
)} with frontmatter description, model and thinking and the prompt as its body; /delegate explains the format too.`;
|
|
321
|
+
)} 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
322
|
}
|
|
313
323
|
return [
|
|
314
324
|
intro,
|
|
@@ -318,7 +328,7 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
318
328
|
(agent) =>
|
|
319
329
|
`- ${agent.name} — ${agent.description} (default ${[agent.model, ...agent.fallback.map((model) => `fallback ${model}`)].join(", ")}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
|
|
320
330
|
agent.excludeTools.length > 0 ? `, no ${agent.excludeTools.join("/")}` : ""
|
|
321
|
-
})`,
|
|
331
|
+
}${agent.tools.length > 0 ? `, tools: ${agent.tools.join("/")}` : ""})`,
|
|
322
332
|
),
|
|
323
333
|
"",
|
|
324
334
|
"`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 +397,7 @@ export default function delegate(
|
|
|
387
397
|
"agents",
|
|
388
398
|
"<name>",
|
|
389
399
|
"AGENT.md",
|
|
390
|
-
)} with frontmatter description, model and thinking, and the prompt as its body`,
|
|
400
|
+
)} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback), and the prompt as its body`,
|
|
391
401
|
);
|
|
392
402
|
}
|
|
393
403
|
const agent = roster.agents.find((candidate) => candidate.name === request.agent);
|
|
@@ -398,7 +408,7 @@ export default function delegate(
|
|
|
398
408
|
}
|
|
399
409
|
if (request.task.trim() === "") throw new Error("a task is required");
|
|
400
410
|
const thinking = request.thinking ?? agent.thinking;
|
|
401
|
-
const { model, skipped, notices } = await chooseModel(request.model, agent, models, proactive);
|
|
411
|
+
const { model, skipped, notices, rest } = await chooseModel(request.model, agent, models, proactive);
|
|
402
412
|
const badThinking = thinkingProblem(thinking);
|
|
403
413
|
if (badThinking !== undefined) throw new Error(badThinking);
|
|
404
414
|
const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
|
|
@@ -408,6 +418,7 @@ export default function delegate(
|
|
|
408
418
|
model,
|
|
409
419
|
skipped,
|
|
410
420
|
notices,
|
|
421
|
+
rest,
|
|
411
422
|
thinking,
|
|
412
423
|
label: request.label,
|
|
413
424
|
autoExit: request.autoExit ?? agent.autoExit,
|
|
@@ -447,6 +458,8 @@ export default function delegate(
|
|
|
447
458
|
start.model,
|
|
448
459
|
"--thinking",
|
|
449
460
|
start.thinking,
|
|
461
|
+
...start.agent.extensions.flatMap((extension) => ["-e", extension]),
|
|
462
|
+
...(start.agent.tools.length > 0 ? ["--tools", start.agent.tools.join(",")] : []),
|
|
450
463
|
...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
|
|
451
464
|
"--append-system-prompt",
|
|
452
465
|
prompt,
|
|
@@ -456,6 +469,7 @@ export default function delegate(
|
|
|
456
469
|
[PARENT_ENV]: parent,
|
|
457
470
|
[AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
|
|
458
471
|
};
|
|
472
|
+
if (start.rest.length > 0) env[FALLBACK_ENV] = start.rest.join(",");
|
|
459
473
|
const sessionDir = process.env[SESSION_DIR_ENV];
|
|
460
474
|
if (sessionDir !== undefined && sessionDir !== "") env[SESSION_DIR_ENV] = sessionDir;
|
|
461
475
|
// Listeners may only append to `args` and add to `env`; there is no veto.
|
|
@@ -536,18 +550,27 @@ export default function delegate(
|
|
|
536
550
|
}),
|
|
537
551
|
async execute(
|
|
538
552
|
_toolCallId: string,
|
|
539
|
-
params: {
|
|
553
|
+
params: {
|
|
554
|
+
agent: string;
|
|
555
|
+
task: string;
|
|
556
|
+
model?: string | null;
|
|
557
|
+
thinking?: string | null;
|
|
558
|
+
label?: string | null;
|
|
559
|
+
auto_exit?: boolean | null;
|
|
560
|
+
},
|
|
540
561
|
_signal: unknown,
|
|
541
562
|
_onUpdate: unknown,
|
|
542
563
|
ctx: ExtensionContext,
|
|
543
564
|
) {
|
|
544
565
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
566
|
+
// Some models fill every optional field with null or the string "null"; both mean unset.
|
|
567
|
+
const given = (value: string | null | undefined) => (value == null || value === "null" ? undefined : value);
|
|
545
568
|
const start = await validate(
|
|
546
569
|
{
|
|
547
570
|
agent: params.agent,
|
|
548
|
-
model: params.model,
|
|
549
|
-
thinking: params.thinking,
|
|
550
|
-
label: params.label,
|
|
571
|
+
model: given(params.model),
|
|
572
|
+
thinking: given(params.thinking),
|
|
573
|
+
label: given(params.label),
|
|
551
574
|
autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
|
|
552
575
|
task: params.task ?? "",
|
|
553
576
|
},
|
package/src/limits.ts
CHANGED
|
@@ -85,7 +85,7 @@ export function activeMark(models: Models, provider: string, id: string, now = D
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
const LIMIT_PATTERN =
|
|
88
|
-
/GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|insufficient_quota|quota exceeded|subscription_sharing_usage_limit_exceeded|usage_limit_reached|usage limit reached|rate_limit_error/i;
|
|
88
|
+
/GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|insufficient_quota|quota exceeded|subscription_sharing_usage_limit_exceeded|usage_limit_reached|usage limit reached|reached (?:their|your) .{0,40}usage limit|rate_limit_error/i;
|
|
89
89
|
|
|
90
90
|
/** The reset time an error text states: "try again in N units", "retry after", or a `resets_at` field. */
|
|
91
91
|
function statedReset(message: string, now: number): number | undefined {
|
package/src/results.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
import { readdirSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
type ExtensionAPI,
|
|
12
|
+
type ExtensionContext,
|
|
13
|
+
keyText,
|
|
14
|
+
type MessageRenderer,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
11
17
|
|
|
12
18
|
import { readMarks } from "./limits.ts";
|
|
13
19
|
|
|
@@ -159,8 +165,8 @@ export function findSession(dir: string | undefined, id: string): string | undef
|
|
|
159
165
|
return name === undefined ? undefined : join(dir, name);
|
|
160
166
|
}
|
|
161
167
|
|
|
162
|
-
/** The line naming a usage-limit mark this delegation
|
|
163
|
-
function limitLine(id: string): string | undefined {
|
|
168
|
+
/** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
|
|
169
|
+
function limitLine(id: string, status: string): string | undefined {
|
|
164
170
|
const now = Date.now();
|
|
165
171
|
const mark = readMarks(now).find((item) => item.delegations.includes(id));
|
|
166
172
|
if (mark === undefined) return undefined;
|
|
@@ -168,7 +174,7 @@ function limitLine(id: string): string | undefined {
|
|
|
168
174
|
const until = new Date(mark.clearsAt).toISOString();
|
|
169
175
|
return `Usage limit: the delegate hit a usage limit on ${scope}; ${
|
|
170
176
|
mark.clearsAt > now ? `it is marked until ${until} and later delegations skip it.` : `it was marked until ${until}.`
|
|
171
|
-
}`;
|
|
177
|
+
}${status === "done" ? " It finished on a fallback model; its session names which." : ""}`;
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
/** The one message the parent sees for a settled delegation. */
|
|
@@ -190,7 +196,7 @@ function resultText(delegation: Delegation, payload: Inbound, session: string |
|
|
|
190
196
|
const cut = cap(reply.body, BODY_CAP);
|
|
191
197
|
const result =
|
|
192
198
|
cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
|
|
193
|
-
const limit = reply.status === "
|
|
199
|
+
const limit = reply.status === "stopped" ? undefined : limitLine(delegation.id, reply.status);
|
|
194
200
|
return [
|
|
195
201
|
`[delegate] Result from ${delegation.agent} (${delegation.model}), delegation ${delegation.id}, request ${delegation.requestId}.`,
|
|
196
202
|
`Status: ${reply.status}`,
|
|
@@ -203,7 +209,24 @@ function resultText(delegation: Delegation, payload: Inbound, session: string |
|
|
|
203
209
|
].join("\n");
|
|
204
210
|
}
|
|
205
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Collapsed, a delegate message shows its first two lines (who answered and the
|
|
214
|
+
* status) and a hint; expanded, Pi's default rendering of the whole message.
|
|
215
|
+
* The model always gets the full content. Ctrl+O toggles it with tool output.
|
|
216
|
+
*/
|
|
217
|
+
export const renderMessage: MessageRenderer = (message, { expanded, outputPad }, theme) => {
|
|
218
|
+
if (expanded || typeof message.content !== "string") return undefined;
|
|
219
|
+
const lines = message.content.split("\n");
|
|
220
|
+
const shown = lines.slice(0, 2).join("\n");
|
|
221
|
+
const hint =
|
|
222
|
+
lines.length > 2 ? `\n${theme.fg("dim", keyText("app.tools.expand"))}${theme.fg("muted", " to expand")}` : "";
|
|
223
|
+
const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
|
|
224
|
+
box.addChild(new Text(`${theme.fg("customMessageText", shown)}${hint}`, 0, 0));
|
|
225
|
+
return box;
|
|
226
|
+
};
|
|
227
|
+
|
|
206
228
|
export function createResults(pi: ExtensionAPI): Results {
|
|
229
|
+
pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
|
|
207
230
|
/** Every recorded delegation by its id, in the order recorded. */
|
|
208
231
|
const byId = new Map<string, Recorded>();
|
|
209
232
|
/** The same records by their request's envelope id, for matching replies. */
|