pi-squire 0.3.2 → 0.5.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 +49 -1
- package/README.md +21 -8
- package/package.json +3 -1
- package/src/child.ts +58 -9
- package/src/index.ts +22 -10
- package/src/limits.ts +1 -1
- package/src/results.ts +30 -6
- package/src/tracking.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- A delegate is now told to put its findings in its final message and to
|
|
14
|
+
message the parent before then only with a question it cannot continue
|
|
15
|
+
without. Delegates mailed progress and findings that the result then
|
|
16
|
+
repeated, so the parent read the same report twice.
|
|
17
|
+
- A delegate's result now reaches a busy parent as a steer, after its current
|
|
18
|
+
tool calls and before its next model call, instead of as a follow-up once
|
|
19
|
+
its run ends. Queued until then, the result arrived after the parent had
|
|
20
|
+
read the envelope itself and answered, and cost a redundant turn.
|
|
21
|
+
`delegation_status` now says a done result arrives as a message on its own.
|
|
22
|
+
`test/delegate.test.ts` pins `steer` in place of `followUp`.
|
|
23
|
+
|
|
24
|
+
## [0.4.0] - 2026-10-13
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- A delegate whose run ends on a usage limit now switches to the agent's next
|
|
29
|
+
fallback that has credentials and is not marked, and continues the task in
|
|
30
|
+
the same session instead of failing. The parent passes the candidates after
|
|
31
|
+
the launched model as `PI_DELEGATE_FALLBACK`; an explicit `model` passes
|
|
32
|
+
none. A `done` result from such a delegate carries the `Usage limit:` line,
|
|
33
|
+
saying it finished on a fallback.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- A delegate message in the chat now shows collapsed, as its first two
|
|
38
|
+
lines, and expands with the tool-output toggle (Ctrl+O), in step with tool
|
|
39
|
+
output. The model still gets the whole message.
|
|
40
|
+
- With auto-exit on, a delegate whose run fails on any provider error now
|
|
41
|
+
closes its window, as after a normal completion; before, only a usage-limit
|
|
42
|
+
failure did. The parent still gets the `failed` reply and the session file
|
|
43
|
+
stays. Only a run the user stopped keeps the window open.
|
|
44
|
+
`test/auto-exit.test.ts` and `test/harness.test.ts` change with it.
|
|
45
|
+
- Refusing an explicit `model` that is usage-limited now names the agent's
|
|
46
|
+
models that are still open, so the caller can omit `model` and let the
|
|
47
|
+
fallbacks apply.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- ChatGPT's "The ChatGPT user has reached their Subscription Sharing usage
|
|
52
|
+
limit" error now counts as a usage limit, so it records a mark and later
|
|
53
|
+
launches skip the provider. Before, it recorded nothing.
|
|
54
|
+
|
|
9
55
|
## [0.3.2] - 2026-10-09
|
|
10
56
|
|
|
11
57
|
### Added
|
|
@@ -169,7 +215,9 @@ author's dotfiles; the changes below are against that copy.
|
|
|
169
215
|
|
|
170
216
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
171
217
|
|
|
172
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.
|
|
218
|
+
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.5.0...HEAD
|
|
219
|
+
[0.5.0]: https://github.com/gvanderclay/pi-squire/compare/v0.4.0...v0.5.0
|
|
220
|
+
[0.4.0]: https://github.com/gvanderclay/pi-squire/compare/v0.3.2...v0.4.0
|
|
173
221
|
[0.3.2]: https://github.com/gvanderclay/pi-squire/compare/v0.3.1...v0.3.2
|
|
174
222
|
[0.3.1]: https://github.com/gvanderclay/pi-squire/compare/v0.3.0...v0.3.1
|
|
175
223
|
[0.3.0]: https://github.com/gvanderclay/pi-squire/compare/v0.2.2...v0.3.0
|
package/README.md
CHANGED
|
@@ -231,7 +231,7 @@ level is refused with close matches and nothing starts.
|
|
|
231
231
|
|
|
232
232
|
<id> researcher (provider/other, thinking high)
|
|
233
233
|
name: researcher-<first 8 of the id>
|
|
234
|
-
state: done — result status: done
|
|
234
|
+
state: done — result status: done; the result reaches this session as a [delegate] message on its own
|
|
235
235
|
window: researcher-<first 8 of the id> (@2)
|
|
236
236
|
auto-exit: off — the window stays open after the result
|
|
237
237
|
session: <session file>
|
|
@@ -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`,
|
|
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
|
|
375
|
-
exits
|
|
376
|
-
|
|
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
|
|
@@ -419,9 +430,11 @@ Delegate session: <session file or id>
|
|
|
419
430
|
delegate's session path is found in the session directory by the delegation
|
|
420
431
|
id, and the id alone is shown when no file exists yet.
|
|
421
432
|
- The body is cut at 32 KiB with the envelope path.
|
|
422
|
-
- The message is sent as a `
|
|
423
|
-
turn when the parent is idle and
|
|
424
|
-
|
|
433
|
+
- The message is sent as a `steer` with `triggerTurn`: a result starts a
|
|
434
|
+
turn when the parent is idle, and when it is mid-turn arrives after the
|
|
435
|
+
current tool calls, before the next model call. A follow-up would wait
|
|
436
|
+
until the parent had answered, by which time it had often read the
|
|
437
|
+
envelope itself, and the late result cost a second, redundant turn.
|
|
425
438
|
- The footer counts the running delegations (`⇄ N running`), clears one entry
|
|
426
439
|
per result or close and hides itself at zero.
|
|
427
440
|
- Each result and each close is recorded in the parent session, and the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-squire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// settles, so its tmux window closes; the settle reply has been sent by
|
|
6
|
+
// then, since the shutdown waits for the next event-loop turn. A failed run
|
|
7
|
+
// exits too (the parent gets the error and the session file stays); only a
|
|
8
|
+
// run the user stopped never exits. A run that ends on a usage limit first
|
|
9
|
+
// moves to the agent's next fallback, from `PI_DELEGATE_FALLBACK`, and
|
|
10
|
+
// continues there; it exits only when no fallback is left. The user
|
|
8
11
|
// taking over — typing here, or stopping a run — turns it off for the rest of
|
|
9
12
|
// the session, with a notice; `/auto-exit` turns it back on. Modelled on
|
|
10
13
|
// edxeth/pi-subagents' `auto-exit` (README "Child lifecycle", `cf6dbf4`).
|
|
@@ -17,10 +20,12 @@
|
|
|
17
20
|
// ponytail: drop the takeover once #5581 lands and the minimum Pi has it.
|
|
18
21
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
22
|
|
|
20
|
-
import { classify, type Response, recordLimit } from "./limits.ts";
|
|
23
|
+
import { activeMark, classify, type Response, recordLimit } from "./limits.ts";
|
|
21
24
|
|
|
22
25
|
/** The launch variable that carries the parent's resolved setting. */
|
|
23
26
|
export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
|
|
27
|
+
/** The launch variable listing, comma-separated, the agent's models after the one launched. */
|
|
28
|
+
export const FALLBACK_ENV = "PI_DELEGATE_FALLBACK";
|
|
24
29
|
|
|
25
30
|
const OFF_NOTICE =
|
|
26
31
|
"delegate: auto-exit is off for this session because you took over here; the window stays open. /auto-exit turns it back on.";
|
|
@@ -47,7 +52,20 @@ function recordUsageLimit(last: Message, ctx: ExtensionContext, response?: Respo
|
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
/** Switch to the first of `fallback` that is usable and not marked, taking each off the list; the one switched to, if any. */
|
|
56
|
+
async function switchModel(pi: ExtensionAPI, ctx: ExtensionContext, fallback: string[]): Promise<string | undefined> {
|
|
57
|
+
for (let next = fallback.shift(); next !== undefined; next = fallback.shift()) {
|
|
58
|
+
const slash = next.indexOf("/");
|
|
59
|
+
const model = ctx.modelRegistry.find(next.slice(0, slash), next.slice(slash + 1));
|
|
60
|
+
if (model === undefined || !ctx.modelRegistry.hasConfiguredAuth(model)) continue;
|
|
61
|
+
if (activeMark(ctx.modelRegistry, model.provider, model.id) !== undefined) continue;
|
|
62
|
+
if (await pi.setModel(model)) return next;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Take over the parent's task request and send it as a user prompt. */
|
|
68
|
+
function registerTakeover(pi: ExtensionAPI, parent: string): void {
|
|
51
69
|
let current: ExtensionContext | undefined;
|
|
52
70
|
pi.on("session_start", async (_event, ctx) => {
|
|
53
71
|
current = ctx;
|
|
@@ -62,9 +80,36 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
62
80
|
current?.ui.notify(`delegate: could not start the task: ${(err as Error).message}`, "error"),
|
|
63
81
|
);
|
|
64
82
|
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A usage limit moves the run to the next fallback that is usable and not
|
|
87
|
+
* marked, and continues it in this session; each fallback is tried once.
|
|
88
|
+
* Returns the setter for the model whose usage limit ended the last run.
|
|
89
|
+
*/
|
|
90
|
+
function registerFallback(pi: ExtensionAPI): (model: string | undefined) => void {
|
|
91
|
+
/** The models still to move to on a usage limit, in order. */
|
|
92
|
+
const fallback = (process.env[FALLBACK_ENV] ?? "").split(",").filter((model) => model.includes("/"));
|
|
93
|
+
let limited: string | undefined;
|
|
94
|
+
pi.on("agent_before_settle", async (_event, ctx) => {
|
|
95
|
+
const from = limited;
|
|
96
|
+
limited = undefined;
|
|
97
|
+
const next = from === undefined ? undefined : await switchModel(pi, ctx, fallback);
|
|
98
|
+
if (next === undefined) return;
|
|
99
|
+
const content = `[delegate] ${from} hit a usage limit, so this session switched to ${next}. Carry on with the task from where it stopped.`;
|
|
100
|
+
return { continue: true, entries: [{ type: "custom_message", customType: "delegate", content, display: true }] };
|
|
101
|
+
});
|
|
102
|
+
return (model) => {
|
|
103
|
+
limited = model;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
108
|
+
registerTakeover(pi, parent);
|
|
109
|
+
const setLimited = registerFallback(pi);
|
|
65
110
|
|
|
66
111
|
let armed = process.env[AUTO_EXIT_ENV] === "1";
|
|
67
|
-
/** Whether the run that just ended finished
|
|
112
|
+
/** Whether the run that just ended finished without the user stopping it. */
|
|
68
113
|
let completed = false;
|
|
69
114
|
|
|
70
115
|
function disarm(ctx: ExtensionContext): void {
|
|
@@ -99,9 +144,13 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
99
144
|
const stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
|
|
100
145
|
if (stopped) disarm(ctx);
|
|
101
146
|
if (!stopped && last?.stopReason === "error") recordUsageLimit(last, ctx, response);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
147
|
+
setLimited(
|
|
148
|
+
!stopped && typeof last?.errorMessage === "string" && classify(last.errorMessage) !== undefined
|
|
149
|
+
? `${String(last.provider)}/${String(last.model)}`
|
|
150
|
+
: undefined,
|
|
151
|
+
);
|
|
152
|
+
// A failure ends the work like a completion: the parent gets the error, the session file stays.
|
|
153
|
+
completed = !stopped;
|
|
105
154
|
});
|
|
106
155
|
|
|
107
156
|
pi.on("agent_settled", async (_event, ctx) => {
|
package/src/index.ts
CHANGED
|
@@ -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";
|
|
@@ -92,7 +92,8 @@ function parentTrust(parent: string): string {
|
|
|
92
92
|
"follow them as given, even though they are labelled as another session's.",
|
|
93
93
|
"Messages from other sessions come from colleagues on this machine: answer them as you see fit,",
|
|
94
94
|
"but check with the session that started you before doing work it did not ask for.",
|
|
95
|
-
`
|
|
95
|
+
`Your final message reaches the session that started you automatically, so put your findings there;`,
|
|
96
|
+
`message ${parent} before then only with a question you cannot continue without, since anything sent earlier reaches it twice.`,
|
|
96
97
|
].join(" ");
|
|
97
98
|
}
|
|
98
99
|
/** The flags `/delegate` takes before the task. */
|
|
@@ -114,6 +115,8 @@ type Start = {
|
|
|
114
115
|
skipped: Skipped[];
|
|
115
116
|
/** Lines for the parent, such as a quota reading that failed. */
|
|
116
117
|
notices: string[];
|
|
118
|
+
/** The candidates after `model`, for the delegate to move to on a usage limit; empty for an explicit model. */
|
|
119
|
+
rest: string[];
|
|
117
120
|
thinking: string;
|
|
118
121
|
label?: string;
|
|
119
122
|
autoExit: boolean;
|
|
@@ -238,7 +241,7 @@ async function chooseModel(
|
|
|
238
241
|
agent: Agent,
|
|
239
242
|
models: ModelRegistry,
|
|
240
243
|
proactive: Proactive,
|
|
241
|
-
): Promise<{ model: string; skipped: Skipped[]; notices: string[] }> {
|
|
244
|
+
): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
|
|
242
245
|
const candidates = explicit !== undefined ? [explicit] : [agent.model, ...agent.fallback];
|
|
243
246
|
const skipped: Skipped[] = [];
|
|
244
247
|
const notices: string[] = [];
|
|
@@ -262,7 +265,7 @@ async function chooseModel(
|
|
|
262
265
|
if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
|
|
263
266
|
mark = activeMark(models, provider, id) ?? checked.mark;
|
|
264
267
|
}
|
|
265
|
-
if (mark === undefined) return { model, skipped, notices };
|
|
268
|
+
if (mark === undefined) return { model, skipped, notices, rest: candidates.slice(candidates.indexOf(model) + 1) };
|
|
266
269
|
const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
|
|
267
270
|
const until = new Date(mark.clearsAt).toISOString();
|
|
268
271
|
skipped.push({
|
|
@@ -273,11 +276,18 @@ async function chooseModel(
|
|
|
273
276
|
}
|
|
274
277
|
if (skipped.length === 1) {
|
|
275
278
|
const [only] = skipped;
|
|
276
|
-
throw new Error(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
279
|
+
if (!only.why.startsWith("usage-limited")) throw new Error(only.reason);
|
|
280
|
+
// An explicit model is never substituted, but the refusal names the agent's models still open.
|
|
281
|
+
const open = [agent.model, ...agent.fallback].filter((model) => {
|
|
282
|
+
if (model === only.model || modelProblem(model, models) !== undefined) return false;
|
|
283
|
+
const slash = model.indexOf("/");
|
|
284
|
+
return activeMark(models, model.slice(0, slash), model.slice(slash + 1)) === undefined;
|
|
285
|
+
});
|
|
286
|
+
const hint =
|
|
287
|
+
explicit !== undefined && open.length > 0
|
|
288
|
+
? ` Omit model to launch ${agent.name} on ${open[0]}${open.length > 1 ? ` (also open: ${open.slice(1).join(", ")})` : ""}, or clear marks with /delegate-clear.`
|
|
289
|
+
: " Clear marks with /delegate-clear.";
|
|
290
|
+
throw new Error(`${only.model} is ${only.reason}.${hint}`);
|
|
281
291
|
}
|
|
282
292
|
throw new Error(
|
|
283
293
|
`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 +409,7 @@ export default function delegate(
|
|
|
399
409
|
}
|
|
400
410
|
if (request.task.trim() === "") throw new Error("a task is required");
|
|
401
411
|
const thinking = request.thinking ?? agent.thinking;
|
|
402
|
-
const { model, skipped, notices } = await chooseModel(request.model, agent, models, proactive);
|
|
412
|
+
const { model, skipped, notices, rest } = await chooseModel(request.model, agent, models, proactive);
|
|
403
413
|
const badThinking = thinkingProblem(thinking);
|
|
404
414
|
if (badThinking !== undefined) throw new Error(badThinking);
|
|
405
415
|
const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
|
|
@@ -409,6 +419,7 @@ export default function delegate(
|
|
|
409
419
|
model,
|
|
410
420
|
skipped,
|
|
411
421
|
notices,
|
|
422
|
+
rest,
|
|
412
423
|
thinking,
|
|
413
424
|
label: request.label,
|
|
414
425
|
autoExit: request.autoExit ?? agent.autoExit,
|
|
@@ -459,6 +470,7 @@ export default function delegate(
|
|
|
459
470
|
[PARENT_ENV]: parent,
|
|
460
471
|
[AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
|
|
461
472
|
};
|
|
473
|
+
if (start.rest.length > 0) env[FALLBACK_ENV] = start.rest.join(",");
|
|
462
474
|
const sessionDir = process.env[SESSION_DIR_ENV];
|
|
463
475
|
if (sessionDir !== undefined && sessionDir !== "") env[SESSION_DIR_ENV] = sessionDir;
|
|
464
476
|
// 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
|
|
10
|
+
import {
|
|
11
|
+
type ExtensionAPI,
|
|
12
|
+
type ExtensionContext,
|
|
13
|
+
keyText,
|
|
14
|
+
type MessageRenderer,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
11
17
|
|
|
12
18
|
import { readMarks } from "./limits.ts";
|
|
13
19
|
|
|
@@ -159,8 +165,8 @@ export function findSession(dir: string | undefined, id: string): string | undef
|
|
|
159
165
|
return name === undefined ? undefined : join(dir, name);
|
|
160
166
|
}
|
|
161
167
|
|
|
162
|
-
/** The line naming a usage-limit mark this delegation
|
|
163
|
-
function limitLine(id: string): string | undefined {
|
|
168
|
+
/** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
|
|
169
|
+
function limitLine(id: string, status: string): string | undefined {
|
|
164
170
|
const now = Date.now();
|
|
165
171
|
const mark = readMarks(now).find((item) => item.delegations.includes(id));
|
|
166
172
|
if (mark === undefined) return undefined;
|
|
@@ -168,7 +174,7 @@ function limitLine(id: string): string | undefined {
|
|
|
168
174
|
const until = new Date(mark.clearsAt).toISOString();
|
|
169
175
|
return `Usage limit: the delegate hit a usage limit on ${scope}; ${
|
|
170
176
|
mark.clearsAt > now ? `it is marked until ${until} and later delegations skip it.` : `it was marked until ${until}.`
|
|
171
|
-
}`;
|
|
177
|
+
}${status === "done" ? " It finished on a fallback model; its session names which." : ""}`;
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
/** The one message the parent sees for a settled delegation. */
|
|
@@ -190,7 +196,7 @@ function resultText(delegation: Delegation, payload: Inbound, session: string |
|
|
|
190
196
|
const cut = cap(reply.body, BODY_CAP);
|
|
191
197
|
const result =
|
|
192
198
|
cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
|
|
193
|
-
const limit = reply.status === "
|
|
199
|
+
const limit = reply.status === "stopped" ? undefined : limitLine(delegation.id, reply.status);
|
|
194
200
|
return [
|
|
195
201
|
`[delegate] Result from ${delegation.agent} (${delegation.model}), delegation ${delegation.id}, request ${delegation.requestId}.`,
|
|
196
202
|
`Status: ${reply.status}`,
|
|
@@ -203,7 +209,24 @@ function resultText(delegation: Delegation, payload: Inbound, session: string |
|
|
|
203
209
|
].join("\n");
|
|
204
210
|
}
|
|
205
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Collapsed, a delegate message shows its first two lines (who answered and the
|
|
214
|
+
* status) and a hint; expanded, Pi's default rendering of the whole message.
|
|
215
|
+
* The model always gets the full content. Ctrl+O toggles it with tool output.
|
|
216
|
+
*/
|
|
217
|
+
export const renderMessage: MessageRenderer = (message, { expanded, outputPad }, theme) => {
|
|
218
|
+
if (expanded || typeof message.content !== "string") return undefined;
|
|
219
|
+
const lines = message.content.split("\n");
|
|
220
|
+
const shown = lines.slice(0, 2).join("\n");
|
|
221
|
+
const hint =
|
|
222
|
+
lines.length > 2 ? `\n${theme.fg("dim", keyText("app.tools.expand"))}${theme.fg("muted", " to expand")}` : "";
|
|
223
|
+
const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
|
|
224
|
+
box.addChild(new Text(`${theme.fg("customMessageText", shown)}${hint}`, 0, 0));
|
|
225
|
+
return box;
|
|
226
|
+
};
|
|
227
|
+
|
|
206
228
|
export function createResults(pi: ExtensionAPI): Results {
|
|
229
|
+
pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
|
|
207
230
|
/** Every recorded delegation by its id, in the order recorded. */
|
|
208
231
|
const byId = new Map<string, Recorded>();
|
|
209
232
|
/** The same records by their request's envelope id, for matching replies. */
|
|
@@ -312,9 +335,10 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
312
335
|
const delegation = byRequest.get(requestId) as Recorded;
|
|
313
336
|
shown.add(reply.id);
|
|
314
337
|
const session = findSession(ctx?.sessionManager.getSessionDir(), delegation.id);
|
|
338
|
+
// Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
|
|
315
339
|
pi.sendMessage(
|
|
316
340
|
{ customType: CUSTOM_TYPE, content: resultText(delegation, payload, session), display: true },
|
|
317
|
-
{ triggerTurn: true, deliverAs: "
|
|
341
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
318
342
|
);
|
|
319
343
|
const result: Result = {
|
|
320
344
|
status: reply.status,
|
package/src/tracking.ts
CHANGED
|
@@ -90,7 +90,7 @@ function stateLine(state: DelegationState, record: Recorded): string {
|
|
|
90
90
|
case "gone":
|
|
91
91
|
return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
|
|
92
92
|
case "done":
|
|
93
|
-
return `done — result status: ${record.result?.status ?? "unknown"}`;
|
|
93
|
+
return `done — result status: ${record.result?.status ?? "unknown"}; the result reaches this session as a [delegate] message on its own`;
|
|
94
94
|
case "closed":
|
|
95
95
|
return "closed — delegation_close recorded it";
|
|
96
96
|
case "window-closed":
|