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 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.3.2...HEAD
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`, 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
@@ -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 `followUp` with `triggerTurn`: a result starts a
423
- turn when the parent is idle and arrives as a follow-up when it is
424
- mid-turn.
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.2",
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 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";
@@ -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
- `You can reach the session that started you at ${parent}.`,
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
- only.why.startsWith("usage-limited")
278
- ? `${only.model} is ${only.reason}. Clear marks with /delegate-clear.`
279
- : only.reason,
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 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. */
@@ -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: "followUp" },
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":