pi-squire 0.9.2 → 0.9.4

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,11 +6,44 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.4] - 2026-10-23
10
+
11
+ ### Changed
12
+
13
+ - The `delegate` tool's row is drawn in the same style as the result message:
14
+ a call line naming the agent and label, then a ◐ header with the delegate's
15
+ name and model, a task preview and any skipped fallback models; expanded,
16
+ the whole task and the delegation id, window, thinking and auto-exit. The
17
+ text the model reads is unchanged.
18
+
19
+ ## [0.9.3] - 2026-10-23
20
+
21
+ ### Changed
22
+
23
+ - A collapsed delegate result previews its first 3 non-blank lines, in a
24
+ brighter color, with heading marks, backticks and bold stripped. Expanded,
25
+ the result comes right after the task under a `Result` label, and the ids
26
+ and files move to the end with the home directory shown as `~`. The
27
+ renderer tests change with it.
28
+
9
29
  ## [0.9.2] - 2026-10-22
10
30
 
11
31
  ### Changed
12
32
 
13
33
  - A delegate's footer shows its agent name in the theme's accent color.
34
+ - Delegate results and closed-without-a-result messages are drawn with a
35
+ header (status icon, delegate name, model) and, collapsed, the first 3
36
+ lines of the result; expanded, the ids and files, the quoted task and the
37
+ result as Markdown. The text the parent model reads is unchanged apart from
38
+ the model. Messages from before this change show as plain text. The
39
+ renderer tests change with it.
40
+
41
+ ### Fixed
42
+
43
+ - A delegate that finished on a different model than it launched on (a
44
+ fallback, or `/model` in its window) is now shown as `launched → finished`
45
+ in the result, the closed-without-a-result message and `delegation_status`,
46
+ read from the delegate's session file. It used to name the launch model.
14
47
 
15
48
  ## [0.9.1] - 2026-10-22
16
49
 
@@ -331,7 +364,9 @@ author's dotfiles; the changes below are against that copy.
331
364
 
332
365
  - The published file list includes `child.ts`, which `index.ts` imports.
333
366
 
334
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.9.2...HEAD
367
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.9.4...HEAD
368
+ [0.9.4]: https://github.com/gvanderclay/pi-squire/compare/v0.9.3...v0.9.4
369
+ [0.9.3]: https://github.com/gvanderclay/pi-squire/compare/v0.9.2...v0.9.3
335
370
  [0.9.2]: https://github.com/gvanderclay/pi-squire/compare/v0.9.1...v0.9.2
336
371
  [0.9.1]: https://github.com/gvanderclay/pi-squire/compare/v0.9.0...v0.9.1
337
372
  [0.9.0]: https://github.com/gvanderclay/pi-squire/compare/v0.8.1...v0.9.0
package/README.md CHANGED
@@ -443,6 +443,22 @@ Delegate session: <session file or id>
443
443
  - The header carries the agent, the model, the delegation id and the request
444
444
  id; the status is the envelope's (`done`, `failed` or
445
445
  `stopped`).
446
+ - The model is the one the delegation was launched on. When the delegate's
447
+ session file shows it finished on another (a fallback after a usage limit,
448
+ or `/model` in its window), it reads `launched → finished`, for example
449
+ `(anthropic/claude-haiku-5-5 → openai/gpt-6-luna)`. `delegation_status`
450
+ reports the same.
451
+ - That text is what the parent model reads. The user sees it drawn instead:
452
+ collapsed, a header with a status icon (✓ done, ✗ failed, ■ stopped), the
453
+ delegate's name and the model, then the first 3 non-blank lines of the
454
+ result with heading marks, backticks and bold stripped; expanded (Ctrl+O),
455
+ the quoted task, the result as Markdown, then the ids and files, with the
456
+ home directory shown as `~`.
457
+ - The `delegate` tool's own row is drawn the same way, so a launch and its
458
+ result match: a call line naming the agent and label, then a header with
459
+ a ◐ started icon, the delegate's name and the model it started on, a preview of the task and any skipped fallback models;
460
+ expanded, the whole task, then the delegation id, window, thinking and
461
+ auto-exit. A refused call keeps its plain error text. The closed-without-a-result message gets the same header with ⊘.
446
462
  - Stopping a delegate's run (Esc) sends no result: the provider holds the
447
463
  task, so the delegation stays running while the user steers it. The next
448
464
  run that completes sends the result, `done`, and its body opens with a note
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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/index.ts CHANGED
@@ -49,6 +49,7 @@ import { Type } from "typebox";
49
49
  import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
50
50
  import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
51
51
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
52
+ import { renderDelegateCall, renderDelegateResult } from "./render.ts";
52
53
  import { createResults, delegateName } from "./results.ts";
53
54
  import { createTmuxClient, type TmuxClient } from "./tmux.ts";
54
55
  import { createTracking, toolResult } from "./tracking.ts";
@@ -596,6 +597,8 @@ export default function delegate(
596
597
  ),
597
598
  }),
598
599
  outputSchema: DelegateSchema,
600
+ renderCall: renderDelegateCall,
601
+ renderResult: renderDelegateResult,
599
602
  // Opens a new window each call and removes nothing. Open world: the delegate calls a remote model and runs its agent's tools unattended.
600
603
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
601
604
  async execute(
package/src/render.ts ADDED
@@ -0,0 +1,197 @@
1
+ // How delegations look in the parent: the `delegate` tool's row and the
2
+ // result or gone message that arrives later share one header and preview
3
+ // style. Both are drawn from structured details, while their text stays what
4
+ // the parent model reads.
5
+ import { homedir } from "node:os";
6
+
7
+ import {
8
+ getMarkdownTheme,
9
+ keyText,
10
+ type MessageRenderer,
11
+ type MessageRenderOptions,
12
+ type Theme,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { Box, type Component, Markdown, Spacer, Text, TruncatedText } from "@earendil-works/pi-tui";
15
+ import type { Static } from "typebox";
16
+
17
+ import type { DelegateSchema } from "./index.ts";
18
+ import type { MessageDetails } from "./results.ts";
19
+
20
+ /** The launch model, or `launch → finished` when the delegate finished on another one. */
21
+ export function modelLabel(model: string, finished: string | undefined): string {
22
+ return finished === undefined || finished === model ? model : `${model} → ${finished}`;
23
+ }
24
+
25
+ /** Lines of the body the collapsed view previews. */
26
+ const PREVIEW_LINES = 3;
27
+
28
+ /** The icon and theme color for each status; an unknown status gets a neutral icon. */
29
+ const STATUS_STYLE: Record<string, { icon: string; color: "accent" | "success" | "error" | "warning" | "muted" }> = {
30
+ started: { icon: "◐", color: "accent" },
31
+ done: { icon: "✓", color: "success" },
32
+ failed: { icon: "✗", color: "error" },
33
+ stopped: { icon: "■", color: "warning" },
34
+ gone: { icon: "⊘", color: "warning" },
35
+ };
36
+
37
+ /** The header line: status icon, the delegate's name and the model it ran on. */
38
+ function header(details: MessageDetails, theme: Theme): string {
39
+ const status = details.kind === "gone" ? "gone" : details.status;
40
+ const known = Object.hasOwn(STATUS_STYLE, status);
41
+ const style = known ? STATUS_STYLE[status] : { icon: "•", color: "muted" as const };
42
+ let trailer = "";
43
+ if (details.kind === "gone") trailer = " closed without a result";
44
+ else if (!known) trailer = ` ${details.status}`;
45
+ return headerLine(theme, style, details.name, modelLabel(details.model, details.finishedModel), trailer);
46
+ }
47
+
48
+ function headerLine(
49
+ theme: Theme,
50
+ style: { icon: string; color: "accent" | "success" | "error" | "warning" | "muted" },
51
+ name: string,
52
+ model: string,
53
+ trailer: string,
54
+ ): string {
55
+ return `${theme.fg(style.color, style.icon)} ${theme.fg("accent", name)} ${theme.fg("muted", model)}${
56
+ trailer === "" ? "" : theme.fg(style.color, trailer)
57
+ }`;
58
+ }
59
+
60
+ function expandHint(theme: Theme): Text {
61
+ return new Text(`${theme.fg("dim", keyText("app.tools.expand"))}${theme.fg("muted", " to expand")}`, 0, 0);
62
+ }
63
+
64
+ /** `path` with the home directory shown as `~`. */
65
+ function tilde(path: string): string {
66
+ const home = homedir();
67
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
68
+ }
69
+
70
+ function metadata(details: MessageDetails): string[] {
71
+ const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
72
+ if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
73
+ else lines.push(`window ${details.windowName}`);
74
+ lines.push(
75
+ `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
76
+ );
77
+ return lines;
78
+ }
79
+
80
+ /**
81
+ * The first non-blank lines of a body, with heading marks, backticks and bold
82
+ * markers stripped. Plain text, not Markdown, because a cut-off fragment can
83
+ * break mid-list or mid-code-block.
84
+ */
85
+ function preview(body: string): string[] {
86
+ return body
87
+ .split("\n")
88
+ .filter((line) => line.trim() !== "")
89
+ .slice(0, PREVIEW_LINES)
90
+ .map((line) =>
91
+ line
92
+ .replace(/^#+\s+/, "")
93
+ .replaceAll("`", "")
94
+ .replaceAll("**", ""),
95
+ );
96
+ }
97
+
98
+ /**
99
+ * A delegate message drawn from its details. Collapsed: a header, a short
100
+ * preview of the result body and an expand hint. Expanded: the header, the
101
+ * quoted task, the body as Markdown, then the ids and files. Ctrl+O toggles it
102
+ * with tool output; the model always gets the full content.
103
+ */
104
+ export function renderMessage(
105
+ message: Parameters<MessageRenderer>[0],
106
+ { expanded, outputPad }: MessageRenderOptions,
107
+ theme: Theme,
108
+ ): Component | undefined {
109
+ const details = message.details as MessageDetails | undefined;
110
+ if (details?.kind !== "result" && details?.kind !== "gone") return undefined;
111
+ const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
112
+ if (!expanded) {
113
+ box.addChild(new TruncatedText(header(details, theme)));
114
+ if (details.kind === "result") {
115
+ for (const line of preview(details.body)) box.addChild(new TruncatedText(theme.fg("muted", line)));
116
+ }
117
+ box.addChild(expandHint(theme));
118
+ return box;
119
+ }
120
+ box.addChild(new Text(header(details, theme), 0, 0));
121
+ const dim = metadata(details);
122
+ if (details.kind === "gone") dim.push("A reply that arrives later is still delivered.");
123
+ else if (details.limit !== undefined) dim.push(details.limit);
124
+ const metadataText = new Text(dim.map((line) => theme.fg("dim", line)).join("\n"), 0, 0);
125
+ if (details.kind === "gone") {
126
+ box.addChild(metadataText);
127
+ return box;
128
+ }
129
+ const { task } = details;
130
+ const quote =
131
+ "note" in task
132
+ ? theme.fg("muted", task.note)
133
+ : `${theme.fg("muted", `Task (${tilde(task.path)}):`)}\n${task.text
134
+ .split("\n")
135
+ .map((line) => theme.fg("muted", `> ${line}`))
136
+ .join("\n")}`;
137
+ box.addChild(new Text(quote, 0, 0));
138
+ box.addChild(new Spacer(1));
139
+ box.addChild(new Text(theme.fg("dim", "Result"), 0, 0));
140
+ box.addChild(new Markdown(details.body, 0, 0, getMarkdownTheme()));
141
+ box.addChild(new Spacer(1));
142
+ box.addChild(metadataText);
143
+ return box;
144
+ }
145
+
146
+ /** The `delegate` tool's call line: the tool, the agent and any label. Arguments may still be streaming. */
147
+ export function renderDelegateCall(args: { agent?: unknown; label?: unknown }, theme: Theme): Component {
148
+ let line = theme.bold("delegate");
149
+ if (typeof args.agent === "string") line += ` ${theme.fg("accent", args.agent)}`;
150
+ if (typeof args.label === "string" && args.label !== "" && args.label !== "null")
151
+ line += ` ${theme.fg("muted", `· ${args.label}`)}`;
152
+ return new Text(line, 0, 0);
153
+ }
154
+
155
+ /**
156
+ * The `delegate` tool's result, in the result message's style: a started
157
+ * header, then a task preview and the hint, or, expanded, the whole task and
158
+ * the ids. A refused or blocked call has no details and keeps its plain text.
159
+ */
160
+ export function renderDelegateResult(
161
+ result: { content: { type: string; text?: string }[]; details?: unknown },
162
+ { expanded }: { expanded: boolean },
163
+ theme: Theme,
164
+ context: { args: { task?: unknown }; isError: boolean },
165
+ ): Component {
166
+ const details = result.details as Partial<Static<typeof DelegateSchema>> | undefined;
167
+ if (context.isError || details?.outcome !== "started") {
168
+ const text = result.content.map((part) => part.text ?? "").join("\n");
169
+ return new Text(context.isError ? theme.fg("error", text) : text, 0, 0);
170
+ }
171
+ const started = details as Static<typeof DelegateSchema>;
172
+ const task = typeof context.args.task === "string" ? context.args.task : "";
173
+ const box = new Box(0, 0);
174
+ box.addChild(new TruncatedText(headerLine(theme, STATUS_STYLE.started, started.windowName, started.model, "")));
175
+ const skipped = started.skipped.map(({ model, reason }) => theme.fg("warning", `skipped ${model}: ${reason}`));
176
+ if (!expanded) {
177
+ for (const line of preview(task)) box.addChild(new TruncatedText(theme.fg("muted", line)));
178
+ for (const line of skipped) box.addChild(new TruncatedText(line));
179
+ box.addChild(expandHint(theme));
180
+ return box;
181
+ }
182
+ const quote = task
183
+ .split("\n")
184
+ .map((line) => theme.fg("muted", `> ${line}`))
185
+ .join("\n");
186
+ box.addChild(new Text(quote, 0, 0));
187
+ if (skipped.length + started.notices.length > 0)
188
+ box.addChild(new Text([...skipped, ...started.notices.map((line) => theme.fg("dim", line))].join("\n"), 0, 0));
189
+ box.addChild(new Spacer(1));
190
+ const ids = [
191
+ `delegation ${started.id}`,
192
+ `window ${started.windowName} (${started.windowId})`,
193
+ `thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
194
+ ];
195
+ box.addChild(new Text(ids.map((line) => theme.fg("dim", line)).join("\n"), 0, 0));
196
+ return box;
197
+ }
package/src/results.ts CHANGED
@@ -4,18 +4,13 @@
4
4
  // the message over, shows the parent one delegate result and records the
5
5
  // result. The records are the one source of truth for a delegation's state,
6
6
  // for the footer, `delegation_status` and `delegation_close`.
7
- import { readdirSync } from "node:fs";
7
+ import { readdirSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
 
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";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
11
 
18
12
  import { readMarks } from "./limits.ts";
13
+ import { modelLabel, renderMessage } from "./render.ts";
19
14
 
20
15
  /** The session entry type both delegation records and results use. */
21
16
  export const CUSTOM_TYPE = "delegate";
@@ -70,8 +65,41 @@ export type Result = {
70
65
  envelopePath: string;
71
66
  /** The delegate's session file, when it could be found. */
72
67
  sessionPath?: string;
68
+ /** The `provider/model` of the delegate's last assistant message, when its session file holds one. */
69
+ finishedModel?: string;
73
70
  };
74
71
 
72
+ /** What both delegate messages carry for their view, beside the text the model reads. */
73
+ type CommonDetails = {
74
+ name: string;
75
+ agent: string;
76
+ /** The model the delegation was launched on. */
77
+ model: string;
78
+ finishedModel?: string;
79
+ id: string;
80
+ requestId: string;
81
+ sessionPath?: string;
82
+ };
83
+
84
+ /** The task as the result quotes it: its text and copy path, or why there is none. */
85
+ export type TaskQuote = { text: string; path: string } | { note: string };
86
+
87
+ /** The details of a delegate message; the renderer draws from these. */
88
+ export type MessageDetails =
89
+ | (CommonDetails & {
90
+ kind: "result";
91
+ /** The envelope's status: `done`, `failed` or `stopped`. */
92
+ status: string;
93
+ envelopePath: string;
94
+ limit?: string;
95
+ task: TaskQuote;
96
+ /** The reply body, capped. */
97
+ body: string;
98
+ })
99
+ | (CommonDetails & { kind: "gone"; windowName: string });
100
+
101
+ type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
102
+
75
103
  /** One delegation as it stands: what was recorded at its start, plus what came after. */
76
104
  export type Recorded = Delegation & {
77
105
  /** Set when a result arrived. */
@@ -165,6 +193,34 @@ export function findSession(dir: string | undefined, id: string): string | undef
165
193
  return name === undefined ? undefined : join(dir, name);
166
194
  }
167
195
 
196
+ /**
197
+ * The `provider/model` of the last assistant message in a delegate's session
198
+ * file: the model that actually answered, after any fallback or `/model`.
199
+ * Undefined when the file is missing or holds no assistant message.
200
+ */
201
+ export function finishedModel(sessionPath: string | undefined): string | undefined {
202
+ if (sessionPath === undefined) return undefined;
203
+ let lines: string[];
204
+ try {
205
+ lines = readFileSync(sessionPath, "utf8").split("\n");
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ for (let i = lines.length - 1; i >= 0; i--) {
210
+ let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
211
+ try {
212
+ entry = JSON.parse(lines[i]);
213
+ } catch {
214
+ continue; // a line still being written, or a blank one
215
+ }
216
+ const message = entry?.message;
217
+ if (entry?.type !== "message" || message?.role !== "assistant") continue;
218
+ if (typeof message.provider === "string" && typeof message.model === "string")
219
+ return `${message.provider}/${message.model}`;
220
+ }
221
+ return undefined;
222
+ }
223
+
168
224
  /** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
169
225
  function limitLine(id: string, status: string): string | undefined {
170
226
  const now = Date.now();
@@ -177,54 +233,67 @@ function limitLine(id: string, status: string): string | undefined {
177
233
  }${status === "done" ? " It finished on a fallback model; its session names which." : ""}`;
178
234
  }
179
235
 
180
- /** The one message the parent sees for a settled delegation. */
181
- function resultText(delegation: Delegation, payload: Inbound, session: string | undefined): string {
236
+ /** The details of a settled delegation's result message. */
237
+ function resultDetails(
238
+ delegation: Delegation,
239
+ payload: Inbound,
240
+ session: string | undefined,
241
+ finished: string | undefined,
242
+ ): ResultDetails {
182
243
  const reply = payload.envelope;
183
244
  const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
184
- let quote: string;
245
+ let task: TaskQuote;
185
246
  if (copy === undefined) {
186
- quote = `[delegate] The task ${delegation.requestId} has no copy in sent/; only its id is known.`;
247
+ task = { note: `[delegate] The task ${delegation.requestId} has no copy in sent/; only its id is known.` };
187
248
  } else {
188
249
  const cut = cap(copy.envelope.body, TASK_CAP);
189
- const lines =
250
+ const text =
190
251
  cut === undefined ? copy.envelope.body : `${cut}\n[delegate] Task cut at 2 KiB; the full copy is ${copy.path}`;
191
- quote = `Task, quoted from ${copy.path}:\n${lines
192
- .split("\n")
193
- .map((line) => `> ${line}`)
194
- .join("\n")}`;
252
+ task = { text, path: copy.path };
195
253
  }
196
254
  const cut = cap(reply.body, BODY_CAP);
197
- const result =
255
+ const body =
198
256
  cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
199
257
  const limit = reply.status === "stopped" ? undefined : limitLine(delegation.id, reply.status);
258
+ return {
259
+ kind: "result",
260
+ status: reply.status,
261
+ name: recordedName(delegation),
262
+ agent: delegation.agent,
263
+ model: delegation.model,
264
+ ...(finished === undefined ? {} : { finishedModel: finished }),
265
+ id: delegation.id,
266
+ requestId: delegation.requestId,
267
+ envelopePath: payload.path,
268
+ ...(session === undefined ? {} : { sessionPath: session }),
269
+ ...(limit === undefined ? {} : { limit }),
270
+ task,
271
+ body,
272
+ };
273
+ }
274
+
275
+ /** The one message the parent model reads for a settled delegation. */
276
+ function resultText(details: ResultDetails): string {
277
+ const { task } = details;
278
+ const quote =
279
+ "note" in task
280
+ ? task.note
281
+ : `Task, quoted from ${task.path}:\n${task.text
282
+ .split("\n")
283
+ .map((line) => `> ${line}`)
284
+ .join("\n")}`;
200
285
  return [
201
- `[delegate] Result from ${delegation.agent} (${delegation.model}), delegation ${delegation.id}, request ${delegation.requestId}.`,
202
- `Status: ${reply.status}`,
203
- ...(limit === undefined ? [] : [limit]),
286
+ `[delegate] Result from ${details.agent} (${modelLabel(details.model, details.finishedModel)}), delegation ${details.id}, request ${details.requestId}.`,
287
+ `Status: ${details.status}`,
288
+ ...(details.limit === undefined ? [] : [details.limit]),
204
289
  quote,
205
- `Envelope: ${payload.path}`,
206
- `Delegate session: ${session ?? delegation.id}`,
290
+ `Envelope: ${details.envelopePath}`,
291
+ `Delegate session: ${details.sessionPath ?? details.id}`,
207
292
  "",
208
- result,
293
+ details.body,
209
294
  ].join("\n");
210
295
  }
211
296
 
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
-
228
297
  export function createResults(pi: ExtensionAPI): Results {
229
298
  pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
230
299
  /** Every recorded delegation by its id, in the order recorded. */
@@ -306,13 +375,26 @@ export function createResults(pi: ExtensionAPI): Results {
306
375
  if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
307
376
  return false;
308
377
  const session = findSession(ctx?.sessionManager.getSessionDir(), record.id);
378
+ const finished = finishedModel(session);
309
379
  pi.appendEntry(CUSTOM_TYPE, { id, gone: true });
310
380
  record.gone = true;
381
+ const details: MessageDetails = {
382
+ kind: "gone",
383
+ name: recordedName(record),
384
+ agent: record.agent,
385
+ model: record.model,
386
+ ...(finished === undefined ? {} : { finishedModel: finished }),
387
+ id: record.id,
388
+ requestId: record.requestId,
389
+ ...(session === undefined ? {} : { sessionPath: session }),
390
+ windowName: record.windowName,
391
+ };
311
392
  pi.sendMessage(
312
393
  {
313
394
  customType: CUSTOM_TYPE,
395
+ details,
314
396
  content: [
315
- `[delegate] Delegation ${record.id} (${record.agent}, ${record.model}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
397
+ `[delegate] Delegation ${record.id} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
316
398
  `Delegate session: ${session ?? record.id}`,
317
399
  "A reply that arrives later is still delivered.",
318
400
  ].join("\n"),
@@ -336,9 +418,11 @@ export function createResults(pi: ExtensionAPI): Results {
336
418
  const delegation = byRequest.get(requestId) as Recorded;
337
419
  shown.add(reply.id);
338
420
  const session = findSession(ctx?.sessionManager.getSessionDir(), delegation.id);
421
+ const finished = finishedModel(session);
422
+ const details = resultDetails(delegation, payload, session, finished);
339
423
  // Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
340
424
  pi.sendMessage(
341
- { customType: CUSTOM_TYPE, content: resultText(delegation, payload, session), display: true },
425
+ { customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
342
426
  { triggerTurn: true, deliverAs: "steer" },
343
427
  );
344
428
  const result: Result = {
@@ -346,6 +430,7 @@ export function createResults(pi: ExtensionAPI): Results {
346
430
  replyId: reply.id,
347
431
  envelopePath: payload.path,
348
432
  ...(session === undefined ? {} : { sessionPath: session }),
433
+ ...(finished === undefined ? {} : { finishedModel: finished }),
349
434
  };
350
435
  pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
351
436
  delegation.result = result;
package/src/tracking.ts CHANGED
@@ -8,6 +8,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
8
  import { Type } from "typebox";
9
9
 
10
10
  import { type Mark, readMarks } from "./limits.ts";
11
+ import { modelLabel } from "./render.ts";
11
12
  import {
12
13
  type DelegationState,
13
14
  deriveState,
@@ -55,7 +56,10 @@ const DelegationSchema = Type.Object({
55
56
  id: Type.String(),
56
57
  name: Type.String(),
57
58
  agent: Type.String(),
58
- model: Type.String(),
59
+ model: Type.String({ description: "The model it was launched on." }),
60
+ finishedModel: Type.Optional(
61
+ Type.String({ description: "The model its result came from, read from its session file; done only." }),
62
+ ),
59
63
  thinking: Type.String(),
60
64
  autoExit: Type.Boolean(),
61
65
  windowId: Type.String(),
@@ -193,7 +197,7 @@ function view(record: Recorded, windows: TmuxWindow[] | undefined, ctx: Extensio
193
197
  function viewText({ record, state, name, sessionPath, lastActive }: View): string {
194
198
  const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
195
199
  const lines = [
196
- `${record.id} ${record.agent} (${record.model}, thinking ${record.thinking})`,
200
+ `${record.id} ${record.agent} (${modelLabel(record.model, record.result?.finishedModel)}, thinking ${record.thinking})`,
197
201
  ` name: ${name}`,
198
202
  ` state: ${stateLine(state, record)}${seen}`,
199
203
  ` window: ${record.windowName} (${record.windowId})`,
@@ -211,6 +215,7 @@ function viewDetails({ record, state, name, sessionPath, lastActive }: View): Re
211
215
  name,
212
216
  agent: record.agent,
213
217
  model: record.model,
218
+ ...(record.result?.finishedModel === undefined ? {} : { finishedModel: record.result.finishedModel }),
214
219
  thinking: record.thinking,
215
220
  autoExit: record.autoExit === true,
216
221
  windowId: record.windowId,