pi-squire 0.3.2 → 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 CHANGED
@@ -6,6 +6,37 @@ 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
+
9
40
  ## [0.3.2] - 2026-10-09
10
41
 
11
42
  ### Added
@@ -169,7 +200,8 @@ author's dotfiles; the changes below are against that copy.
169
200
 
170
201
  - The published file list includes `child.ts`, which `index.ts` imports.
171
202
 
172
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.3.2...HEAD
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
173
205
  [0.3.2]: https://github.com/gvanderclay/pi-squire/compare/v0.3.1...v0.3.2
174
206
  [0.3.1]: https://github.com/gvanderclay/pi-squire/compare/v0.3.0...v0.3.1
175
207
  [0.3.0]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...v0.3.0
package/README.md CHANGED
@@ -290,7 +290,9 @@ A mark clears at the reset time the error states ("try again in 30 minutes",
290
290
  hours. When the failed response's headers state a reset (`retry-after`,
291
291
  `anthropic-ratelimit-*-reset`, `x-ratelimit-reset-requests` or `-tokens`),
292
292
  that time wins over the error text. Until then `delegate` and `/delegate`
293
- refuse a marked model passed as `model` / `--model`, and say when it clears.
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.
294
296
 
295
297
  Without an explicit model, the delegation launches on the agent's model, or
296
298
  else on the first of its `fallback` models that has credentials and is not
@@ -300,6 +302,14 @@ the result details list the skipped models under `skipped`. When every
300
302
  candidate is marked or unusable, the call is refused with one line per
301
303
  candidate and why it was skipped.
302
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
+
303
313
  Before a launch, pi-squire also reads the OpenCode Go quota for a paid
304
314
  `opencode-go` candidate (`GET https://opencode.ai/zen/go/v1/usage`, with the
305
315
  provider's API key). A window at 100% or more, or one the provider reports as
@@ -371,9 +381,10 @@ The parent resolves each delegation's auto-exit from the call
371
381
  down (`ctx.shutdown()`) on the next event-loop turn, after the settle
372
382
  reply has been sent, so its tmux window closes. The session file stays, and
373
383
  `pi --session <path>` reopens it.
374
- - A run that ended stopped, or failed on anything but a usage limit, never
375
- exits the delegate. A usage-limit failure is final until the quota resets,
376
- so it exits like a normal completion; the parent still gets the failed reply.
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.
377
388
  - The user taking over turns it off for the rest of the session, with a
378
389
  notice: typed or RPC input, or a run the user stopped (Esc).
379
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.2",
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/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 as a normal completion, so its tmux window closes; the settle
6
- // reply has been sent by then, since the shutdown waits for the next
7
- // event-loop turn. A run that ended stopped or failed never exits. The user
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
- export function registerChild(pi: ExtensionAPI, parent: string): void {
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 normally: not stopped, not failed. */
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
- // 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);
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
@@ -47,7 +47,7 @@ import {
47
47
  import { Type } from "typebox";
48
48
 
49
49
  import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
50
- import { AUTO_EXIT_ENV, registerChild } from "./child.ts";
50
+ import { AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
51
51
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
52
52
  import { createResults, delegateName } from "./results.ts";
53
53
  import { createTmuxClient, type TmuxClient } from "./tmux.ts";
@@ -114,6 +114,8 @@ type Start = {
114
114
  skipped: Skipped[];
115
115
  /** Lines for the parent, such as a quota reading that failed. */
116
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[];
117
119
  thinking: string;
118
120
  label?: string;
119
121
  autoExit: boolean;
@@ -238,7 +240,7 @@ async function chooseModel(
238
240
  agent: Agent,
239
241
  models: ModelRegistry,
240
242
  proactive: Proactive,
241
- ): Promise<{ model: string; skipped: Skipped[]; notices: string[] }> {
243
+ ): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
242
244
  const candidates = explicit !== undefined ? [explicit] : [agent.model, ...agent.fallback];
243
245
  const skipped: Skipped[] = [];
244
246
  const notices: string[] = [];
@@ -262,7 +264,7 @@ async function chooseModel(
262
264
  if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
263
265
  mark = activeMark(models, provider, id) ?? checked.mark;
264
266
  }
265
- if (mark === undefined) return { model, skipped, notices };
267
+ if (mark === undefined) return { model, skipped, notices, rest: candidates.slice(candidates.indexOf(model) + 1) };
266
268
  const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
267
269
  const until = new Date(mark.clearsAt).toISOString();
268
270
  skipped.push({
@@ -273,11 +275,18 @@ async function chooseModel(
273
275
  }
274
276
  if (skipped.length === 1) {
275
277
  const [only] = skipped;
276
- throw new Error(
277
- only.why.startsWith("usage-limited")
278
- ? `${only.model} is ${only.reason}. Clear marks with /delegate-clear.`
279
- : only.reason,
280
- );
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}`);
281
290
  }
282
291
  throw new Error(
283
292
  `no model is available for agent ${agent.name}:\n${skipped.map((item) => `- ${item.model}: ${item.reason}`).join("\n")}\nClear marks with /delegate-clear.`,
@@ -399,7 +408,7 @@ export default function delegate(
399
408
  }
400
409
  if (request.task.trim() === "") throw new Error("a task is required");
401
410
  const thinking = request.thinking ?? agent.thinking;
402
- const { model, skipped, notices } = await chooseModel(request.model, agent, models, proactive);
411
+ const { model, skipped, notices, rest } = await chooseModel(request.model, agent, models, proactive);
403
412
  const badThinking = thinkingProblem(thinking);
404
413
  if (badThinking !== undefined) throw new Error(badThinking);
405
414
  const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
@@ -409,6 +418,7 @@ export default function delegate(
409
418
  model,
410
419
  skipped,
411
420
  notices,
421
+ rest,
412
422
  thinking,
413
423
  label: request.label,
414
424
  autoExit: request.autoExit ?? agent.autoExit,
@@ -459,6 +469,7 @@ export default function delegate(
459
469
  [PARENT_ENV]: parent,
460
470
  [AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
461
471
  };
472
+ if (start.rest.length > 0) env[FALLBACK_ENV] = start.rest.join(",");
462
473
  const sessionDir = process.env[SESSION_DIR_ENV];
463
474
  if (sessionDir !== undefined && sessionDir !== "") env[SESSION_DIR_ENV] = sessionDir;
464
475
  // Listeners may only append to `args` and add to `env`; there is no veto.
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 type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
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's failure recorded, if any. */
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 === "failed" ? limitLine(delegation.id) : undefined;
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. */