pi-squire 0.2.2 → 0.3.1
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 +65 -1
- package/README.md +116 -6
- package/package.json +1 -1
- package/src/agents.ts +18 -3
- package/src/child.ts +33 -2
- package/src/index.ts +130 -15
- package/src/limits.ts +242 -0
- package/src/results.ts +16 -0
- package/src/tmux.ts +10 -3
- package/src/tracking.ts +74 -12
- package/src/usage.ts +432 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,68 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.1] - 2026-10-03
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- With auto-exit on, a delegate whose run ends on a usage-limit error now
|
|
14
|
+
closes its window, as after a normal completion. The parent still gets the
|
|
15
|
+
`failed` reply and the session file stays. Other failures still leave the
|
|
16
|
+
window open. `test/harness.test.ts` now fails its run with a `400` instead
|
|
17
|
+
of a `429`, since a `429` is a usage limit and now exits.
|
|
18
|
+
|
|
19
|
+
## [0.3.0] - 2026-10-03
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- A delegate whose run ends on a usage-limit error (a quota message, or a
|
|
24
|
+
`429` that survived Pi's retries) records a mark in
|
|
25
|
+
`pi-squire-limits.json` in Pi's agent directory. Every Pi session then
|
|
26
|
+
refuses to launch a delegate on a marked model until the mark clears.
|
|
27
|
+
- A usage-limit mark clears at the reset time in the failed response's headers
|
|
28
|
+
(`retry-after`, `anthropic-ratelimit-*-reset`, `x-ratelimit-reset-*`) when
|
|
29
|
+
Pi reports them, ahead of the time in the error text.
|
|
30
|
+
- An agent's `fallback:` frontmatter (comma-separated or a YAML list of
|
|
31
|
+
`provider/id`) names models to launch on, in order, when its `model` is
|
|
32
|
+
usage-limited or unusable. The tool result and the command notice name the
|
|
33
|
+
substitute and the skipped model; the call is refused only when every
|
|
34
|
+
candidate is skipped. An explicit `model` is never substituted.
|
|
35
|
+
- A `failed` result from a delegate that recorded a usage-limit mark gets a
|
|
36
|
+
`Usage limit:` line after `Status:`, naming the scope and clear time.
|
|
37
|
+
`delegation_status` ends with a `Usage-limit marks:` block of the active
|
|
38
|
+
marks, and its details gain `marks`.
|
|
39
|
+
- `/delegate-clear <all|provider|provider/model>` removes usage-limit marks and
|
|
40
|
+
their repeat-hit history. An argument matching no active mark changes
|
|
41
|
+
nothing and lists the active marks. No tool can clear marks.
|
|
42
|
+
- Before a launch on a paid `opencode-go` model, pi-squire reads the OpenCode
|
|
43
|
+
Go quota (cached for 60 seconds). A window at 100% or `rate-limited` skips
|
|
44
|
+
the model and records a `proactive` mark that clears at the window's reset
|
|
45
|
+
time. A failed reading never blocks a launch: the result and notice say
|
|
46
|
+
"Could not read opencode-go quota: …; launched without a proactive check.",
|
|
47
|
+
and `delegation_status` shows the error in `readingErrors` until a later
|
|
48
|
+
reading succeeds. `/delegate-clear` also drops the cached reading. A proactive
|
|
49
|
+
mark that cannot be written does not stop the launch.
|
|
50
|
+
- Before a launch, pi-squire reads Claude Code's usage cache for an
|
|
51
|
+
`anthropic` candidate on a Claude subscription login and skips it when its
|
|
52
|
+
5-hour or 7-day window is at 100%, or its model-scoped week is and names that
|
|
53
|
+
model. A stale cache is refreshed in the background with
|
|
54
|
+
`claude -p /usage`, at most every 5 minutes, without delaying the launch.
|
|
55
|
+
- A usage-limit mark clears early when a launch considers the marked
|
|
56
|
+
`opencode-go` or OAuth `anthropic` candidate and a re-check (at most every
|
|
57
|
+
10 minutes per provider) shows every window under 100% and a window cycle
|
|
58
|
+
that began after the mark was recorded. Headroom alone never clears a mark,
|
|
59
|
+
and a model-scoped mark on `opencode-go` never clears early.
|
|
60
|
+
- `delegation_status` shows how long ago a running delegate last wrote its
|
|
61
|
+
session file, at the end of its `state:` line (`last active 12 min ago`),
|
|
62
|
+
and adds the same moment to its details as `lastActiveAt`. A long gap may
|
|
63
|
+
mean the delegate is stuck but does not prove it.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- A tmux call that hangs now fails after 10 seconds with
|
|
68
|
+
`tmux <subcommand> timed out after 10 s`, instead of hanging `delegate`,
|
|
69
|
+
`delegation_status`, `delegation_close` and the background poll.
|
|
70
|
+
|
|
9
71
|
## [0.2.2] - 2026-10-02
|
|
10
72
|
|
|
11
73
|
### Fixed
|
|
@@ -86,7 +148,9 @@ author's dotfiles; the changes below are against that copy.
|
|
|
86
148
|
|
|
87
149
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
88
150
|
|
|
89
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.
|
|
151
|
+
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.3.1...HEAD
|
|
152
|
+
[0.3.1]: https://github.com/gvanderclay/pi-squire/compare/v0.3.0...v0.3.1
|
|
153
|
+
[0.3.0]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...v0.3.0
|
|
90
154
|
[0.2.2]: https://github.com/gvanderclay/pi-squire/compare/v0.2.1...v0.2.2
|
|
91
155
|
[0.2.1]: https://github.com/gvanderclay/pi-squire/compare/v0.2.0...v0.2.1
|
|
92
156
|
[0.2.0]: https://github.com/gvanderclay/pi-squire/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -115,10 +115,10 @@ temporary folder, not as text on the command line.
|
|
|
115
115
|
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
|
-
`auto-exit` (`true` or `false`, default `true`; see [Auto-exit](#auto-exit))
|
|
119
|
-
|
|
120
|
-
Pi's own prompt; it reaches the child as a path to a private file
|
|
121
|
-
system temporary folder, so the text stays off the command line. The
|
|
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,
|
|
120
|
+
appended to Pi's own prompt; it reaches the child as a path to a private file
|
|
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`.
|
|
123
123
|
|
|
124
124
|
`exclude-tools` names the tools the delegate goes without, as a
|
|
@@ -133,6 +133,11 @@ 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
|
+
`fallback` lists models to try, in order, when the agent's `model` is
|
|
137
|
+
usage-limited (see [Usage limits](#usage-limits)), as a comma-separated list
|
|
138
|
+
or a YAML list of `provider/id`. An entry that is not `provider/id` is dropped
|
|
139
|
+
with a warning; the agent still loads.
|
|
140
|
+
|
|
136
141
|
The body is followed by one fixed final-message line and a paragraph naming the
|
|
137
142
|
parent session's address. The delegate's task arrives as a message from that
|
|
138
143
|
session, and that message and every later one from the same address are
|
|
@@ -201,7 +206,7 @@ level is refused with close matches and nothing starts.
|
|
|
201
206
|
|
|
202
207
|
<id> scout (provider/model, thinking low)
|
|
203
208
|
name: scout-<first 8 of the id>
|
|
204
|
-
state: running — the window is open and no result has arrived
|
|
209
|
+
state: running — the window is open and no result has arrived (last active 12 min ago)
|
|
205
210
|
window: scout-<first 8 of the id> (@1)
|
|
206
211
|
auto-exit: on — the delegate closes its window after a normal completion unless the user took over there
|
|
207
212
|
session: <the delegate's session file, or its id when there is none yet>
|
|
@@ -220,6 +225,9 @@ level is refused with close matches and nothing starts.
|
|
|
220
225
|
- **Running** means the window is open and no result has arrived. An open
|
|
221
226
|
window is connection, not task state: the delegate may still be working, or
|
|
222
227
|
waiting for the user, and only a result means done.
|
|
228
|
+
When the delegate's session file exists, the line ends with when it was last
|
|
229
|
+
written (`just now`, `12 min ago`, `2 h 5 min ago`). That is a hint, not
|
|
230
|
+
proof: a long gap may mean the delegate is stuck, or just thinking.
|
|
223
231
|
- **Done** comes from the recorded result, and shows the envelope's status
|
|
224
232
|
(`done`, `failed` or `stopped`).
|
|
225
233
|
- A delegate that closed itself by auto-exit reads **done**: its result
|
|
@@ -236,11 +244,104 @@ level is refused with close matches and nothing starts.
|
|
|
236
244
|
- When tmux cannot be asked about a window, the state is `unknown`, never
|
|
237
245
|
`closed`, because a failed check is not evidence the window is gone.
|
|
238
246
|
|
|
247
|
+
`delegation_status` also ends with a `Usage-limit marks:` block, one line per
|
|
248
|
+
active mark: its scope, source (`reactive` or `proactive`), clear time and
|
|
249
|
+
reason (cut to about 120 characters), then one line per provider whose last
|
|
250
|
+
quota reading failed. The block is left out when there is neither, and shows
|
|
251
|
+
even when no delegation is recorded. The details gain a `marks` array of
|
|
252
|
+
`{ scope, source, reason, clearsAt }` and a `readingErrors` array (see
|
|
253
|
+
[Usage limits](#usage-limits)).
|
|
254
|
+
|
|
239
255
|
`delegation_close` kills the window and records the close even when the
|
|
240
256
|
result has already arrived. When the window is already gone it only records
|
|
241
257
|
the close. An unknown or already closed id returns a message and kills
|
|
242
258
|
nothing. The delegate's session file and result envelope stay on disk.
|
|
243
259
|
|
|
260
|
+
## Usage limits
|
|
261
|
+
|
|
262
|
+
When a delegate's run ends on a usage-limit error, it records a mark in
|
|
263
|
+
`pi-squire-limits.json` in Pi's agent directory, which every Pi session on the
|
|
264
|
+
machine reads. Quota messages count, and so does a `429` that survived Pi's
|
|
265
|
+
retries; overloaded and `5xx` errors do not. A mark covers the failing
|
|
266
|
+
model's whole provider, except for a free model (an id ending in `-free`, or
|
|
267
|
+
zero cost in Pi's registry), which is never covered by a provider mark and,
|
|
268
|
+
when it is the one that failed, is marked alone.
|
|
269
|
+
|
|
270
|
+
A mark clears at the reset time the error states ("try again in 30 minutes",
|
|
271
|
+
`resets_at`), otherwise after 5 minutes, doubling on each repeat hit up to 6
|
|
272
|
+
hours. When the failed response's headers state a reset (`retry-after`,
|
|
273
|
+
`anthropic-ratelimit-*-reset`, `x-ratelimit-reset-requests` or `-tokens`),
|
|
274
|
+
that time wins over the error text. Until then `delegate` and `/delegate`
|
|
275
|
+
refuse a marked model passed as `model` / `--model`, and say when it clears.
|
|
276
|
+
|
|
277
|
+
Without an explicit model, the delegation launches on the agent's model, or
|
|
278
|
+
else on the first of its `fallback` models that has credentials and is not
|
|
279
|
+
marked. The tool result and the command's notice then say, for example, "Used
|
|
280
|
+
fallback opencode-go/b because alpha/fast-model is usage-limited until …", and
|
|
281
|
+
the result details list the skipped models under `skipped`. When every
|
|
282
|
+
candidate is marked or unusable, the call is refused with one line per
|
|
283
|
+
candidate and why it was skipped.
|
|
284
|
+
|
|
285
|
+
Before a launch, pi-squire also reads the OpenCode Go quota for a paid
|
|
286
|
+
`opencode-go` candidate (`GET https://opencode.ai/zen/go/v1/usage`, with the
|
|
287
|
+
provider's API key). A window at 100% or more, or one the provider reports as
|
|
288
|
+
`rate-limited`, skips the candidate and records a `proactive` mark on
|
|
289
|
+
`opencode-go`, for example "Go 5h window at 100%", that clears at that window's
|
|
290
|
+
reset time (the 5-minute cooldown when none is given). A reading is reused for
|
|
291
|
+
60 seconds. Free models and other providers are never read, and a candidate that
|
|
292
|
+
is already marked is skipped without a read. The key is sent only to
|
|
293
|
+
`https://opencode.ai`, with redirects refused: a provider or model base URL on
|
|
294
|
+
another origin means no request at all.
|
|
295
|
+
|
|
296
|
+
For an `anthropic` candidate used through a Claude subscription (an OAuth
|
|
297
|
+
login; API-key users get no proactive check and rely on reactive marks),
|
|
298
|
+
pi-squire reads Claude Code's own usage cache, the `cachedUsageUtilization`
|
|
299
|
+
field of `$CLAUDE_CONFIG_DIR/.claude.json` (`~/.claude.json` when the variable
|
|
300
|
+
is unset). It never calls Anthropic's usage endpoint. A 5-hour or 7-day window
|
|
301
|
+
at 100% or more records a `proactive` mark on `anthropic`, cleared at that
|
|
302
|
+
window's reset time. A model-scoped weekly window at 100% (for example Opus)
|
|
303
|
+
marks only the `provider/model` candidates whose name or id contains the scope
|
|
304
|
+
name, so other Anthropic models stay usable. The check reads the cache as it
|
|
305
|
+
stands and never waits: when the cache is missing or older than 5 minutes,
|
|
306
|
+
pi-squire starts `claude -p /usage --no-session-persistence` in the background
|
|
307
|
+
(in a temporary directory, output discarded, killed after 30 seconds) so Claude
|
|
308
|
+
Code rewrites it, at most once every 5 minutes per Pi process. It starts only
|
|
309
|
+
when Claude Code state exists (`$CLAUDE_CONFIG_DIR`, or `~/.claude.json` or
|
|
310
|
+
`~/.claude` when the variable is unset). A missing `claude` binary is ignored. A
|
|
311
|
+
window at 100% whose reset time has already passed counts as reset and blocks
|
|
312
|
+
nothing.
|
|
313
|
+
|
|
314
|
+
A failed reading (for Go: no key, a base URL off the Go origin, a non-2xx
|
|
315
|
+
answer, an unreadable body, a timeout, a network error; for Claude: a missing or
|
|
316
|
+
unreadable cache) never blocks a launch. The tool result and the command's
|
|
317
|
+
notice gain "Could not read <provider> quota: <reason>; launched without a
|
|
318
|
+
proactive check.", and `delegation_status` lists the provider's last reading
|
|
319
|
+
error and its time under the `Usage-limit marks:` block and in a `readingErrors`
|
|
320
|
+
array of `{ provider, reason, at }`.
|
|
321
|
+
|
|
322
|
+
A mark can also clear early. When a launch considers a marked `opencode-go` or
|
|
323
|
+
OAuth `anthropic` candidate, pi-squire re-checks that provider's quota, at most
|
|
324
|
+
once every 10 minutes per provider and never in the background, and not for a
|
|
325
|
+
mark recorded less than 10 minutes ago. It clears a mark only when the reading
|
|
326
|
+
succeeded, every applying window is under 100%, and a window began a new cycle
|
|
327
|
+
after the mark was recorded (its reset time minus its length is later than the
|
|
328
|
+
mark's time); headroom alone never clears one, because a quota endpoint can show
|
|
329
|
+
headroom while the account still answers 429. A model-scoped Claude mark clears
|
|
330
|
+
only when its scoped window has reset; a model-scoped mark on `opencode-go`
|
|
331
|
+
never clears early, because Go has no scoped window. A window without a reset time or
|
|
332
|
+
length cannot prove a reset. The tool result and the command's notice then say
|
|
333
|
+
"Cleared the usage-limit mark on <scope>: its quota has reset since the mark was
|
|
334
|
+
recorded." A failed re-check keeps the mark, lists the reading error in
|
|
335
|
+
`delegation_status`, and adds no note.
|
|
336
|
+
|
|
337
|
+
A mark can be stale (the quota came back, or the plan changed). Only you clear
|
|
338
|
+
one, with `/delegate-clear <target>`: `all`, a provider (its mark and every
|
|
339
|
+
model mark under it), or `provider/model` (that model's mark and the provider
|
|
340
|
+
mark covering it). Clearing also forgets the repeat-hit history, so the next
|
|
341
|
+
hit starts at 5 minutes again, and drops the cached quota reading so the next
|
|
342
|
+
launch reads it afresh. With no argument, or one matching no active
|
|
343
|
+
mark, it changes nothing and lists the active marks. No tool clears marks.
|
|
344
|
+
|
|
244
345
|
## Auto-exit
|
|
245
346
|
|
|
246
347
|
The parent resolves each delegation's auto-exit from the call
|
|
@@ -252,7 +353,9 @@ The parent resolves each delegation's auto-exit from the call
|
|
|
252
353
|
down (`ctx.shutdown()`) on the next event-loop turn, after the settle
|
|
253
354
|
reply has been sent, so its tmux window closes. The session file stays, and
|
|
254
355
|
`pi --session <path>` reopens it.
|
|
255
|
-
- A run that ended stopped or failed
|
|
356
|
+
- A run that ended stopped, or failed on anything but a usage limit, never
|
|
357
|
+
exits the delegate. A usage-limit failure is final until the quota resets,
|
|
358
|
+
so it exits like a normal completion; the parent still gets the failed reply.
|
|
256
359
|
- The user taking over turns it off for the rest of the session, with a
|
|
257
360
|
notice: typed or RPC input, or a run the user stopped (Esc).
|
|
258
361
|
- `/auto-exit` inside the delegate turns it on again for the next normal
|
|
@@ -284,6 +387,13 @@ Delegate session: <session file or id>
|
|
|
284
387
|
task, so the delegation stays running while the user steers it. The next
|
|
285
388
|
run that completes sends the result, `done`, and its body opens with a note
|
|
286
389
|
that the user took over partway.
|
|
390
|
+
- A `failed` reply from a delegate that recorded a usage-limit mark (see
|
|
391
|
+
[Usage limits](#usage-limits)) gets one more line right after `Status:`,
|
|
392
|
+
for example `Usage limit: the delegate hit a usage limit on provider
|
|
393
|
+
opencode-go; it is marked until 2026-10-03T12:05:00.000Z and later
|
|
394
|
+
delegations skip it.` (a model scope reads `on model provider/id`; a mark
|
|
395
|
+
that has since cleared reads `it was marked until …`). A failed reply with
|
|
396
|
+
no such mark is unchanged.
|
|
287
397
|
- The task is quoted from the request copy the provider puts on the payload,
|
|
288
398
|
capped at 2 KiB with the copy's `sent/` path. A request with no copy is
|
|
289
399
|
named by id alone.
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type Agent = {
|
|
|
33
33
|
autoExit: boolean;
|
|
34
34
|
/** Tools the delegate goes without, from `exclude-tools`; empty means all of them. */
|
|
35
35
|
excludeTools: string[];
|
|
36
|
+
/** Models to try, in order, when `model` is usage-limited; from `fallback`, empty when absent. */
|
|
37
|
+
fallback: string[];
|
|
36
38
|
/** The body of `AGENT.md`: the delegate's system prompt. */
|
|
37
39
|
prompt: string;
|
|
38
40
|
};
|
|
@@ -54,7 +56,7 @@ function parseAgent(
|
|
|
54
56
|
path: string,
|
|
55
57
|
text: string,
|
|
56
58
|
tools: readonly string[] | undefined,
|
|
57
|
-
): { agent?: Agent; warning?: string } {
|
|
59
|
+
): { agent?: Agent; warning?: string; notes?: string[] } {
|
|
58
60
|
let frontmatter: Record<string, unknown>;
|
|
59
61
|
let body: string;
|
|
60
62
|
try {
|
|
@@ -94,7 +96,18 @@ function parseAgent(
|
|
|
94
96
|
return {
|
|
95
97
|
warning: `${path} has exclude-tools ${JSON.stringify(unknownTool)}, not a tool this session has; skipped`,
|
|
96
98
|
};
|
|
99
|
+
// A bad fallback entry is dropped with a note; it never costs the agent.
|
|
100
|
+
const notes: string[] = [];
|
|
101
|
+
const listedFallback = toolList(frontmatter.fallback);
|
|
102
|
+
if (listedFallback === null)
|
|
103
|
+
notes.push(`${path} has fallback ${JSON.stringify(frontmatter.fallback)}, not a list of provider/id; ignored`);
|
|
104
|
+
const fallback = (listedFallback ?? []).filter((entry) => {
|
|
105
|
+
if (MODEL.test(entry)) return true;
|
|
106
|
+
notes.push(`${path} has fallback ${JSON.stringify(entry)}, not provider/id; dropped`);
|
|
107
|
+
return false;
|
|
108
|
+
});
|
|
97
109
|
return {
|
|
110
|
+
notes,
|
|
98
111
|
agent: {
|
|
99
112
|
name,
|
|
100
113
|
description: description as string,
|
|
@@ -102,12 +115,13 @@ function parseAgent(
|
|
|
102
115
|
thinking: thinking as Thinking,
|
|
103
116
|
autoExit: autoExit ?? true,
|
|
104
117
|
excludeTools,
|
|
118
|
+
fallback,
|
|
105
119
|
prompt: body,
|
|
106
120
|
},
|
|
107
121
|
};
|
|
108
122
|
}
|
|
109
123
|
|
|
110
|
-
/** A
|
|
124
|
+
/** A list of names (tools or models), comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
|
|
111
125
|
function toolList(value: unknown): string[] | undefined | null {
|
|
112
126
|
if (value === undefined || value === null) return undefined;
|
|
113
127
|
const names = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : null;
|
|
@@ -153,7 +167,8 @@ export function readRoster(agentDir: string, tools?: readonly string[]): Roster
|
|
|
153
167
|
warnings.push(`${path} is not under a usable agent name; skipped`);
|
|
154
168
|
continue;
|
|
155
169
|
}
|
|
156
|
-
const { agent, warning } = parseAgent(name, path, text, tools);
|
|
170
|
+
const { agent, warning, notes } = parseAgent(name, path, text, tools);
|
|
171
|
+
warnings.push(...(notes ?? []));
|
|
157
172
|
if (agent !== undefined) agents.push(agent);
|
|
158
173
|
else if (warning !== undefined) warnings.push(warning);
|
|
159
174
|
}
|
package/src/child.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
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 { classify, type Response, recordLimit } from "./limits.ts";
|
|
21
|
+
|
|
20
22
|
/** The launch variable that carries the parent's resolved setting. */
|
|
21
23
|
export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
|
|
22
24
|
|
|
@@ -24,10 +26,27 @@ const OFF_NOTICE =
|
|
|
24
26
|
"delegate: auto-exit is off for this session because you took over here; the window stays open. /auto-exit turns it back on.";
|
|
25
27
|
const ON_NOTICE = "delegate: auto-exit is on; this delegate closes after its next normal completion.";
|
|
26
28
|
|
|
27
|
-
type Message = { role?: string; stopReason?: unknown };
|
|
29
|
+
type Message = { role?: string; stopReason?: unknown; errorMessage?: unknown; provider?: unknown; model?: unknown };
|
|
28
30
|
|
|
29
31
|
type Inbound = { envelope?: { from?: unknown; kind?: unknown; body?: unknown }; handled?: boolean };
|
|
30
32
|
|
|
33
|
+
/** A run that ended on a usage-limit error leaves a mark for every Pi session. */
|
|
34
|
+
function recordUsageLimit(last: Message, ctx: ExtensionContext, response?: Response): void {
|
|
35
|
+
const { provider, model, errorMessage } = last;
|
|
36
|
+
if (typeof provider !== "string" || typeof model !== "string" || typeof errorMessage !== "string") return;
|
|
37
|
+
try {
|
|
38
|
+
recordLimit(ctx.modelRegistry, {
|
|
39
|
+
provider,
|
|
40
|
+
model,
|
|
41
|
+
message: errorMessage,
|
|
42
|
+
delegation: ctx.sessionManager.getSessionId(),
|
|
43
|
+
response,
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
// A mark that cannot be written must not break the run's end.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
32
51
|
let current: ExtensionContext | undefined;
|
|
33
52
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -59,6 +78,15 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
59
78
|
if (event.source !== "extension") disarm(ctx);
|
|
60
79
|
});
|
|
61
80
|
|
|
81
|
+
// The latest provider response of the current run; its headers may state the reset.
|
|
82
|
+
let response: Response | undefined;
|
|
83
|
+
pi.on("agent_start", async () => {
|
|
84
|
+
response = undefined;
|
|
85
|
+
});
|
|
86
|
+
pi.on("after_provider_response", async (event) => {
|
|
87
|
+
response = { status: event.status, headers: event.headers };
|
|
88
|
+
});
|
|
89
|
+
|
|
62
90
|
// Stopped mid-text the last message says `aborted`; stopped during a tool
|
|
63
91
|
// call Pi 0.99.1 ends with an `error` message, and only the signal tells a
|
|
64
92
|
// stop from a real error. Either way it is the user taking over.
|
|
@@ -70,7 +98,10 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
70
98
|
}
|
|
71
99
|
const stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
|
|
72
100
|
if (stopped) disarm(ctx);
|
|
73
|
-
|
|
101
|
+
if (!stopped && last?.stopReason === "error") recordUsageLimit(last, ctx, response);
|
|
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);
|
|
74
105
|
});
|
|
75
106
|
|
|
76
107
|
pi.on("agent_settled", async (_event, ctx) => {
|
package/src/index.ts
CHANGED
|
@@ -47,9 +47,11 @@ import { Type } from "typebox";
|
|
|
47
47
|
|
|
48
48
|
import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
|
|
49
49
|
import { AUTO_EXIT_ENV, registerChild } from "./child.ts";
|
|
50
|
+
import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
|
|
50
51
|
import { createResults, delegateName } from "./results.ts";
|
|
51
52
|
import { createTmuxClient, type TmuxClient } from "./tmux.ts";
|
|
52
53
|
import { createTracking, toolResult } from "./tracking.ts";
|
|
54
|
+
import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
|
|
53
55
|
|
|
54
56
|
/** The hook a provider of `message:*` answers with the written request. */
|
|
55
57
|
const SEND = "message:send";
|
|
@@ -104,7 +106,20 @@ type SendPayload = { to: unknown; body: unknown; envelope?: { id?: unknown }; er
|
|
|
104
106
|
type LaunchPayload = { args: string[]; env: Record<string, string>; agent: string };
|
|
105
107
|
type Parsed = { agent: string; model?: string; thinking?: string; label?: string; autoExit?: boolean; task: string };
|
|
106
108
|
/** A request that passed the roster, model, thinking and label checks. */
|
|
107
|
-
type Start = {
|
|
109
|
+
type Start = {
|
|
110
|
+
agent: Agent;
|
|
111
|
+
model: string;
|
|
112
|
+
/** Candidates passed over for `model`, with why; empty unless a fallback was used. */
|
|
113
|
+
skipped: Skipped[];
|
|
114
|
+
/** Lines for the parent, such as a quota reading that failed. */
|
|
115
|
+
notices: string[];
|
|
116
|
+
thinking: string;
|
|
117
|
+
label?: string;
|
|
118
|
+
autoExit: boolean;
|
|
119
|
+
task: string;
|
|
120
|
+
};
|
|
121
|
+
/** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
|
|
122
|
+
type Skipped = { model: string; why: string; reason: string };
|
|
108
123
|
/** A delegation that is open in a tmux window. */
|
|
109
124
|
type Launched = { id: string; name: string; windowId: string; windowName: string; requestId: string };
|
|
110
125
|
|
|
@@ -209,6 +224,71 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
|
|
|
209
224
|
return `unknown model ${JSON.stringify(value)}${close.length > 0 ? `; close matches: ${close.join(", ")}` : ""}`;
|
|
210
225
|
}
|
|
211
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The model for a launch. An explicit model is the only candidate; otherwise
|
|
229
|
+
* the agent's model then its fallbacks. The first candidate that passes
|
|
230
|
+
* `modelProblem` and has no active mark wins; the rest are returned as skipped.
|
|
231
|
+
* A paid candidate on a provider with a quota check is read first, and
|
|
232
|
+
* skipped when its quota is used up. Refuses, listing every candidate, when
|
|
233
|
+
* none is left.
|
|
234
|
+
*/
|
|
235
|
+
async function chooseModel(
|
|
236
|
+
explicit: string | undefined,
|
|
237
|
+
agent: Agent,
|
|
238
|
+
models: ModelRegistry,
|
|
239
|
+
proactive: Proactive,
|
|
240
|
+
): Promise<{ model: string; skipped: Skipped[]; notices: string[] }> {
|
|
241
|
+
const candidates = explicit !== undefined ? [explicit] : [agent.model, ...agent.fallback];
|
|
242
|
+
const skipped: Skipped[] = [];
|
|
243
|
+
const notices: string[] = [];
|
|
244
|
+
for (const model of candidates) {
|
|
245
|
+
const problem = modelProblem(model, models);
|
|
246
|
+
if (problem !== undefined) {
|
|
247
|
+
skipped.push({ model, why: `unavailable (${problem})`, reason: problem });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const slash = model.indexOf("/");
|
|
251
|
+
const provider = model.slice(0, slash);
|
|
252
|
+
const id = model.slice(slash + 1);
|
|
253
|
+
let mark = activeMark(models, provider, id);
|
|
254
|
+
if (mark !== undefined) {
|
|
255
|
+
const cleared = await proactive.recheck(models, provider, id);
|
|
256
|
+
for (const line of cleared) if (!notices.includes(line)) notices.push(line);
|
|
257
|
+
if (cleared.length > 0) mark = activeMark(models, provider, id);
|
|
258
|
+
}
|
|
259
|
+
if (mark === undefined && !isFree(models, provider, id)) {
|
|
260
|
+
const checked = await proactive.check(models, provider, id);
|
|
261
|
+
if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
|
|
262
|
+
mark = activeMark(models, provider, id) ?? checked.mark;
|
|
263
|
+
}
|
|
264
|
+
if (mark === undefined) return { model, skipped, notices };
|
|
265
|
+
const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
|
|
266
|
+
const until = new Date(mark.clearsAt).toISOString();
|
|
267
|
+
skipped.push({
|
|
268
|
+
model,
|
|
269
|
+
why: `usage-limited until ${until}`,
|
|
270
|
+
reason: `usage-limited (${what}) until ${until}: ${mark.reason}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (skipped.length === 1) {
|
|
274
|
+
const [only] = skipped;
|
|
275
|
+
throw new Error(
|
|
276
|
+
only.why.startsWith("usage-limited")
|
|
277
|
+
? `${only.model} is ${only.reason}. Clear marks with /delegate-clear.`
|
|
278
|
+
: only.reason,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
throw new Error(
|
|
282
|
+
`no model is available for agent ${agent.name}:\n${skipped.map((item) => `- ${item.model}: ${item.reason}`).join("\n")}\nClear marks with /delegate-clear.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The sentence that says a fallback ran, or "" when the agent's own model did. */
|
|
287
|
+
const fallbackNote = (start: Start): string =>
|
|
288
|
+
start.skipped.length === 0
|
|
289
|
+
? ""
|
|
290
|
+
: `Used fallback ${start.model} because ${start.skipped.map((item) => `${item.model} is ${item.why}`).join("; ")}.`;
|
|
291
|
+
|
|
212
292
|
/** Why `value` is not a thinking level, or undefined when it is. */
|
|
213
293
|
function thinkingProblem(value: string): string | undefined {
|
|
214
294
|
if (isThinking(value)) return undefined;
|
|
@@ -236,7 +316,7 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
236
316
|
"Agents:",
|
|
237
317
|
...agents.map(
|
|
238
318
|
(agent) =>
|
|
239
|
-
`- ${agent.name} — ${agent.description} (default ${agent.model}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
|
|
319
|
+
`- ${agent.name} — ${agent.description} (default ${[agent.model, ...agent.fallback.map((model) => `fallback ${model}`)].join(", ")}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
|
|
240
320
|
agent.excludeTools.length > 0 ? `, no ${agent.excludeTools.join("/")}` : ""
|
|
241
321
|
})`,
|
|
242
322
|
),
|
|
@@ -249,11 +329,15 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
249
329
|
}
|
|
250
330
|
|
|
251
331
|
/**
|
|
252
|
-
* Register `/delegate` and the three tools. The second
|
|
253
|
-
* test-only
|
|
254
|
-
* package's documented contract.
|
|
332
|
+
* Register `/delegate` and the three tools. The second and third
|
|
333
|
+
* parameters are test-only seams, for tmux and for the usage readers: Pi
|
|
334
|
+
* passes only `pi`, so they are not part of the package's documented contract.
|
|
255
335
|
*/
|
|
256
|
-
export default function delegate(
|
|
336
|
+
export default function delegate(
|
|
337
|
+
pi: ExtensionAPI,
|
|
338
|
+
tmux: TmuxClient = createTmuxClient(),
|
|
339
|
+
usage: UsageClient = createUsageClient(),
|
|
340
|
+
): void {
|
|
257
341
|
// A delegate registers only its auto-exit side: `delegate` is one level deep (Q27).
|
|
258
342
|
const parentId = process.env[PARENT_ENV] ?? "";
|
|
259
343
|
if (parentId !== "") {
|
|
@@ -263,7 +347,8 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
263
347
|
|
|
264
348
|
const results = createResults(pi);
|
|
265
349
|
|
|
266
|
-
const
|
|
350
|
+
const proactive = createProactive(usage);
|
|
351
|
+
const tracking = createTracking(pi, results, tmux, proactive.errors);
|
|
267
352
|
|
|
268
353
|
/** Captured because `getArgumentCompletions` is called without a context. */
|
|
269
354
|
let registry: ModelRegistry | undefined;
|
|
@@ -294,7 +379,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
294
379
|
}
|
|
295
380
|
|
|
296
381
|
/** The request as a start, or an error naming what to fix. */
|
|
297
|
-
function validate(request: Parsed, roster: Roster, models: ModelRegistry): Start {
|
|
382
|
+
async function validate(request: Parsed, roster: Roster, models: ModelRegistry): Promise<Start> {
|
|
298
383
|
if (roster.agents.length === 0) {
|
|
299
384
|
throw new Error(
|
|
300
385
|
`no agents are defined; add ${join(
|
|
@@ -312,10 +397,8 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
312
397
|
);
|
|
313
398
|
}
|
|
314
399
|
if (request.task.trim() === "") throw new Error("a task is required");
|
|
315
|
-
const model = request.model ?? agent.model;
|
|
316
400
|
const thinking = request.thinking ?? agent.thinking;
|
|
317
|
-
const
|
|
318
|
-
if (badModel !== undefined) throw new Error(badModel);
|
|
401
|
+
const { model, skipped, notices } = await chooseModel(request.model, agent, models, proactive);
|
|
319
402
|
const badThinking = thinkingProblem(thinking);
|
|
320
403
|
if (badThinking !== undefined) throw new Error(badThinking);
|
|
321
404
|
const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
|
|
@@ -323,6 +406,8 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
323
406
|
return {
|
|
324
407
|
agent,
|
|
325
408
|
model,
|
|
409
|
+
skipped,
|
|
410
|
+
notices,
|
|
326
411
|
thinking,
|
|
327
412
|
label: request.label,
|
|
328
413
|
autoExit: request.autoExit ?? agent.autoExit,
|
|
@@ -405,7 +490,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
405
490
|
}
|
|
406
491
|
|
|
407
492
|
const started = (launched: Launched, start: Start): string =>
|
|
408
|
-
`delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}`;
|
|
493
|
+
`delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
|
|
409
494
|
|
|
410
495
|
async function command(input: string, ctx: ExtensionContext): Promise<void> {
|
|
411
496
|
const fail = (message: string) => ctx.ui.notify(`delegate: ${message}`, "error");
|
|
@@ -415,7 +500,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
415
500
|
return;
|
|
416
501
|
}
|
|
417
502
|
try {
|
|
418
|
-
const start = validate(outcome.parsed, rosterFor(ctx), ctx.modelRegistry);
|
|
503
|
+
const start = await validate(outcome.parsed, rosterFor(ctx), ctx.modelRegistry);
|
|
419
504
|
const launched = await launch(start, ctx);
|
|
420
505
|
ctx.ui.notify(started(launched, start), "info");
|
|
421
506
|
} catch (err) {
|
|
@@ -457,7 +542,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
457
542
|
ctx: ExtensionContext,
|
|
458
543
|
) {
|
|
459
544
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
460
|
-
const start = validate(
|
|
545
|
+
const start = await validate(
|
|
461
546
|
{
|
|
462
547
|
agent: params.agent,
|
|
463
548
|
model: params.model,
|
|
@@ -472,12 +557,14 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
472
557
|
const launched = await launch(start, ctx);
|
|
473
558
|
ctx.ui.notify(started(launched, start), "info");
|
|
474
559
|
return toolResult(
|
|
475
|
-
`Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}. It runs in the background and its result arrives as a message; delegation_status reports it and delegation_close ends it
|
|
560
|
+
`Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}. It runs in the background and its result arrives as a message; delegation_status reports it and delegation_close ends it.${start.skipped.length > 0 ? ` ${fallbackNote(start)}` : ""}${start.notices.map((line) => ` ${line}`).join("")}`,
|
|
476
561
|
{
|
|
477
562
|
outcome: "started",
|
|
478
563
|
id: launched.id,
|
|
479
564
|
agent: start.agent.name,
|
|
480
565
|
model: start.model,
|
|
566
|
+
skipped: start.skipped.map(({ model, reason }) => ({ model, reason })),
|
|
567
|
+
notices: start.notices,
|
|
481
568
|
thinking: start.thinking,
|
|
482
569
|
autoExit: start.autoExit,
|
|
483
570
|
windowId: launched.windowId,
|
|
@@ -520,6 +607,34 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
|
|
|
520
607
|
handler: command,
|
|
521
608
|
});
|
|
522
609
|
|
|
610
|
+
pi.registerCommand("delegate-clear", {
|
|
611
|
+
description: "Clear usage-limit marks: all, a provider, or a provider/model",
|
|
612
|
+
getArgumentCompletions: (prefix: string) => {
|
|
613
|
+
const hits = [
|
|
614
|
+
"all",
|
|
615
|
+
...readMarks()
|
|
616
|
+
.filter((m) => m.clearsAt > Date.now())
|
|
617
|
+
.map((m) => m.scope),
|
|
618
|
+
].filter((v) => v.startsWith(prefix.trim()));
|
|
619
|
+
return hits.length > 0 ? hits.map((value) => ({ value, label: value })) : null;
|
|
620
|
+
},
|
|
621
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
622
|
+
const target = args.trim();
|
|
623
|
+
const cleared = target === "" ? [] : clearMarks(target);
|
|
624
|
+
if (cleared.length > 0) proactive.clear();
|
|
625
|
+
const active = readMarks().filter((m) => m.clearsAt > Date.now());
|
|
626
|
+
const list = active.map((m) => `${m.scope} until ${new Date(m.clearsAt).toISOString()}`).join(", ");
|
|
627
|
+
ctx.ui.notify(
|
|
628
|
+
cleared.length > 0
|
|
629
|
+
? `delegate-clear: cleared ${cleared.join(", ")}.`
|
|
630
|
+
: active.length === 0
|
|
631
|
+
? "delegate-clear: no usage-limit marks are active."
|
|
632
|
+
: `delegate-clear: ${target === "" ? "name a mark to clear" : `no active mark matches "${target}"`}. Active marks: ${list}. Use all, a provider or provider/model.`,
|
|
633
|
+
"info",
|
|
634
|
+
);
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
|
|
523
638
|
pi.registerTool(delegateTool() as never);
|
|
524
639
|
tracking.registerTools();
|
|
525
640
|
}
|