pi-squire 0.2.2 → 0.3.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 CHANGED
@@ -6,6 +6,58 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-03
10
+
11
+ ### Added
12
+
13
+ - A delegate whose run ends on a usage-limit error (a quota message, or a
14
+ `429` that survived Pi's retries) records a mark in
15
+ `pi-squire-limits.json` in Pi's agent directory. Every Pi session then
16
+ refuses to launch a delegate on a marked model until the mark clears.
17
+ - A usage-limit mark clears at the reset time in the failed response's headers
18
+ (`retry-after`, `anthropic-ratelimit-*-reset`, `x-ratelimit-reset-*`) when
19
+ Pi reports them, ahead of the time in the error text.
20
+ - An agent's `fallback:` frontmatter (comma-separated or a YAML list of
21
+ `provider/id`) names models to launch on, in order, when its `model` is
22
+ usage-limited or unusable. The tool result and the command notice name the
23
+ substitute and the skipped model; the call is refused only when every
24
+ candidate is skipped. An explicit `model` is never substituted.
25
+ - A `failed` result from a delegate that recorded a usage-limit mark gets a
26
+ `Usage limit:` line after `Status:`, naming the scope and clear time.
27
+ `delegation_status` ends with a `Usage-limit marks:` block of the active
28
+ marks, and its details gain `marks`.
29
+ - `/delegate-clear <all|provider|provider/model>` removes usage-limit marks and
30
+ their repeat-hit history. An argument matching no active mark changes
31
+ nothing and lists the active marks. No tool can clear marks.
32
+ - Before a launch on a paid `opencode-go` model, pi-squire reads the OpenCode
33
+ Go quota (cached for 60 seconds). A window at 100% or `rate-limited` skips
34
+ the model and records a `proactive` mark that clears at the window's reset
35
+ time. A failed reading never blocks a launch: the result and notice say
36
+ "Could not read opencode-go quota: …; launched without a proactive check.",
37
+ and `delegation_status` shows the error in `readingErrors` until a later
38
+ reading succeeds. `/delegate-clear` also drops the cached reading. A proactive
39
+ mark that cannot be written does not stop the launch.
40
+ - Before a launch, pi-squire reads Claude Code's usage cache for an
41
+ `anthropic` candidate on a Claude subscription login and skips it when its
42
+ 5-hour or 7-day window is at 100%, or its model-scoped week is and names that
43
+ model. A stale cache is refreshed in the background with
44
+ `claude -p /usage`, at most every 5 minutes, without delaying the launch.
45
+ - A usage-limit mark clears early when a launch considers the marked
46
+ `opencode-go` or OAuth `anthropic` candidate and a re-check (at most every
47
+ 10 minutes per provider) shows every window under 100% and a window cycle
48
+ that began after the mark was recorded. Headroom alone never clears a mark,
49
+ and a model-scoped mark on `opencode-go` never clears early.
50
+ - `delegation_status` shows how long ago a running delegate last wrote its
51
+ session file, at the end of its `state:` line (`last active 12 min ago`),
52
+ and adds the same moment to its details as `lastActiveAt`. A long gap may
53
+ mean the delegate is stuck but does not prove it.
54
+
55
+ ### Fixed
56
+
57
+ - A tmux call that hangs now fails after 10 seconds with
58
+ `tmux <subcommand> timed out after 10 s`, instead of hanging `delegate`,
59
+ `delegation_status`, `delegation_close` and the background poll.
60
+
9
61
  ## [0.2.2] - 2026-10-02
10
62
 
11
63
  ### Fixed
@@ -86,7 +138,8 @@ author's dotfiles; the changes below are against that copy.
86
138
 
87
139
  - The published file list includes `child.ts`, which `index.ts` imports.
88
140
 
89
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...HEAD
141
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.3.0...HEAD
142
+ [0.3.0]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...v0.3.0
90
143
  [0.2.2]: https://github.com/gvanderclay/pi-squire/compare/v0.2.1...v0.2.2
91
144
  [0.2.1]: https://github.com/gvanderclay/pi-squire/compare/v0.2.0...v0.2.1
92
145
  [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
- and `exclude-tools`. The body is the delegate's system prompt, appended to
120
- Pi's own prompt; it reaches the child as a path to a private file under the
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
@@ -284,6 +385,13 @@ Delegate session: <session file or id>
284
385
  task, so the delegation stays running while the user steers it. The next
285
386
  run that completes sends the result, `done`, and its body opens with a note
286
387
  that the user took over partway.
388
+ - A `failed` reply from a delegate that recorded a usage-limit mark (see
389
+ [Usage limits](#usage-limits)) gets one more line right after `Status:`,
390
+ for example `Usage limit: the delegate hit a usage limit on provider
391
+ opencode-go; it is marked until 2026-10-03T12:05:00.000Z and later
392
+ delegations skip it.` (a model scope reads `on model provider/id`; a mark
393
+ that has since cleared reads `it was marked until …`). A failed reply with
394
+ no such mark is unchanged.
287
395
  - The task is quoted from the request copy the provider puts on the payload,
288
396
  capped at 2 KiB with the copy's `sent/` path. A request with no copy is
289
397
  named by id alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.2.2",
3
+ "version": "0.3.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",
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 tool list, comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
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 { 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,6 +98,7 @@ 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);
101
+ if (!stopped && last?.stopReason === "error") recordUsageLimit(last, ctx, response);
73
102
  completed = !stopped && last?.stopReason !== "error";
74
103
  });
75
104
 
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 = { agent: Agent; model: string; thinking: string; label?: string; autoExit: boolean; task: string };
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 parameter is a
253
- * test-only seam for tmux: Pi passes only `pi`, so it is not part of the
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(pi: ExtensionAPI, tmux: TmuxClient = createTmuxClient()): void {
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 tracking = createTracking(pi, results, tmux);
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 badModel = modelProblem(model, models);
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
  }