oira666_pi-subagent 0.3.14 → 0.3.15

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/README.md CHANGED
@@ -152,6 +152,22 @@ pi --no-subagent-prevent-cycles # allow cycles (not recommended)
152
152
  | `PI_SUBAGENT_MAX_PARALLEL_TASKS` | `30` | Max tasks per single call |
153
153
  | `PI_SUBAGENT_MAX_CONCURRENCY` | `8` | Max subagents running simultaneously |
154
154
 
155
+ ## Child Process Environment
156
+
157
+ Children inherit all provider, authentication, proxy, home, temp, and Pi
158
+ environment variables. The runner also repairs the executable search path for
159
+ elevated Windows PowerShell and pnpm installations: it normalizes duplicate
160
+ `Path`/`PATH` keys and adds the Node directory, `PNPM_HOME`, npm's user bin,
161
+ `%LOCALAPPDATA%\\pnpm`, and `%SystemRoot%\\System32`. This also applies to every
162
+ nested child, so tools and sub-subagents use the same working environment.
163
+
164
+ Pi itself is relaunched with the current runtime and entrypoint
165
+ (`process.execPath process.argv[1]`). The extension does not inspect npm/pnpm
166
+ shims or assume package names, `node_modules` locations, or Pi `dist` layouts.
167
+ This also supports Bun and other Node-compatible runtimes with the same process
168
+ semantics. Embedded hosts without a script entrypoint can use
169
+ `PI_SUBAGENT_PI_COMMAND` and `PI_SUBAGENT_PI_ARGS_PREFIX` explicitly.
170
+
155
171
  ## Subagent Liveness Timeouts
156
172
 
157
173
  A delegated process cannot block its parents forever. The runner applies a
@@ -176,11 +192,12 @@ reported immediately as failures.
176
192
 
177
193
  ## Timestamps & Status Footer
178
194
 
179
- Subagent tool calls and live activity lines render a dim `hh:mm:ss` timestamp
180
- (call start, per-subagent run start, and each live log entry). The collapsed tool
181
- row keeps the normal compact progress text. Press `Ctrl+O` to expand it into the
182
- subagent tree immediately; every running node shows its latest six activity
183
- lines (thinking, tool starts/completions, and completed turns).
195
+ Subagent tool calls and live activity lines render a dim `hh:mm:ss` timestamp.
196
+ The collapsed view shows every direct child as `Name (agent-type)`, two prompt
197
+ lines, its current status, and `last action`. That timestamp is the newest
198
+ activity anywhere in the child's recursive subtree, so active grandchildren
199
+ keep their ancestor visibly alive. Press `Ctrl+O` to expand the full recursive
200
+ tree, including every agent's complete prompt and latest activity lines.
184
201
 
185
202
  In the interactive TUI the extension publishes the combined `total` usage line
186
203
  (parent + all subagents, recursively) via Pi's normal `ctx.ui.setStatus()`
@@ -188,7 +205,7 @@ status line. Pi renders all extension statuses on the same footer status line.
188
205
 
189
206
  ## Steering Running Subagents
190
207
 
191
- While a `subagents` tool call is running, mid-stream steering input can be broadcast to one or more child agents. The extension uses Pi's `InputEvent.streamingBehavior` metadata when available, so idle prompts and queued follow-ups continue to the parent normally; only true `steer` inputs open the broadcast routing prompt.
208
+ While a `subagents` tool call is running, mid-stream steering input can be broadcast to one or more child agents. Targets are selected by human name; nested targets use paths such as `John > Maria > Elena`. The extension uses Pi's `InputEvent.streamingBehavior` metadata when available, so idle prompts and queued follow-ups continue to the parent normally; only true `steer` inputs open the broadcast routing prompt.
192
209
 
193
210
  ## Subagent Session Resume
194
211
 
@@ -215,16 +232,16 @@ Note: crash-resume covers `subagents` calls only. An interrupted `resume_subagen
215
232
 
216
233
  ## Resumable Subagents by Name (`resume_subagents`)
217
234
 
218
- Every subagent run is assigned a unique, durable name derived from its agent
219
- type plus a per-type counter — `code-writer-01`, `code-writer-02`,
220
- `code-reviewer-01`, ... The name is returned together with the subagent's
221
- results and shown in the TUI tree.
235
+ Every subagent run is assigned a random, durable human first name from a bundled
236
+ list of 500 popular American names — for example `John`, `Maria`, or `Elena`.
237
+ Names are never reused anywhere in the same delegation tree. The name is
238
+ returned together with the agent type and shown in every TUI view.
222
239
 
223
240
  The `resume_subagents` tool continues named subagents with a new task while
224
241
  preserving their full previous context:
225
242
 
226
243
  ```json
227
- { "resumes": [{ "subagent": "code-writer-01", "task": "Now also update the tests." }] }
244
+ { "resumes": [{ "subagent": "John", "task": "Now also update the tests." }] }
228
245
  ```
229
246
 
230
247
  Naming is deliberately unambiguous: `agent` (in `subagents`) selects an agent
@@ -0,0 +1,56 @@
1
+ /** 500 common American first names used for human-friendly subagent identities. */
2
+ export const AMERICAN_NAMES = [
3
+ // Common masculine names
4
+ "James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph", "Thomas", "Christopher",
5
+ "Charles", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "Steven", "Andrew", "Paul", "Joshua",
6
+ "Kenneth", "Kevin", "Brian", "George", "Timothy", "Ronald", "Edward", "Jason", "Jeffrey", "Ryan",
7
+ "Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen", "Larry", "Justin", "Scott", "Brandon",
8
+ "Benjamin", "Samuel", "Gregory", "Alexander", "Patrick", "Frank", "Raymond", "Jack", "Dennis", "Jerry",
9
+ "Tyler", "Aaron", "Jose", "Adam", "Nathan", "Henry", "Douglas", "Zachary", "Peter", "Kyle",
10
+ "Walter", "Ethan", "Jeremy", "Harold", "Keith", "Christian", "Roger", "Noah", "Gerald", "Carl",
11
+ "Terry", "Sean", "Austin", "Arthur", "Lawrence", "Jesse", "Dylan", "Bryan", "Joe", "Jordan",
12
+ "Billy", "Bruce", "Albert", "Willie", "Gabriel", "Logan", "Alan", "Juan", "Wayne", "Roy",
13
+ "Ralph", "Randy", "Eugene", "Vincent", "Russell", "Elijah", "Louis", "Bobby", "Philip", "Johnny",
14
+ "Howard", "Isaac", "Harry", "Victor", "Martin", "Ernest", "Phillip", "Todd", "Craig", "Shawn",
15
+ "Clarence", "Carlos", "Caleb", "Blake", "Mason", "Luke", "Liam", "Owen", "Nathaniel", "Cameron",
16
+ "Hunter", "Connor", "Adrian", "Julian", "Jeremiah", "Angel", "Isaiah", "Evan", "Colton", "Gavin",
17
+ "Dominic", "Cooper", "Ian", "Carson", "Jaxon", "Theodore", "Hudson", "Lincoln", "Asher", "Wyatt",
18
+ "Leo", "Ezra", "Miles", "Micah", "Silas", "Roman", "Wesley", "Brooks", "Bennett", "Parker",
19
+ "Beau", "Weston", "Damian", "Sawyer", "Emmett", "Harrison", "Cole", "Maxwell", "Holden", "Antonio",
20
+ "Miguel", "Oscar", "Diego", "Alejandro", "Marco", "Luis", "Javier", "Sergio", "Ricardo", "Manuel",
21
+ "Roberto", "Francisco", "Eduardo", "Fernando", "Ruben", "Raul", "Andres", "Hector", "Edgar", "Mario",
22
+ "Derek", "Trevor", "Travis", "Cody", "Dustin", "Corey", "Brett", "Chad", "Bradley", "Spencer",
23
+ "Grant", "Joel", "Dean", "Dale", "Glenn", "Stanley", "Leonard", "Francis", "Herbert", "Frederick",
24
+ "Norman", "Melvin", "Marvin", "Gordon", "Allen", "Bernard", "Clifford", "Warren", "Curtis", "Darren",
25
+ "Troy", "Marcus", "Derrick", "Andre", "Terrence", "Maurice", "Darius", "Malcolm", "Xavier", "Preston",
26
+ "Grayson", "Easton", "Nolan", "Everett", "Axel", "Emmanuel", "Kingston", "Elliot", "Tim", "Simon",
27
+ "Riley", "Finn", "Jasper", "Milo", "Rowan", "Declan", "Graham", "Max", "Nash", "Reid",
28
+ "Lane", "Tucker", "Carter", "Chase", "Coleman", "Bryce", "Hayden", "Aidan", "Jonah", "Jonas",
29
+
30
+ // Common feminine names
31
+ "Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Barbara", "Susan", "Jessica", "Sarah", "Karen",
32
+ "Lisa", "Nancy", "Betty", "Margaret", "Sandra", "Ashley", "Kimberly", "Emily", "Donna", "Michelle",
33
+ "Carol", "Amanda", "Melissa", "Deborah", "Stephanie", "Dorothy", "Rebecca", "Sharon", "Laura", "Cynthia",
34
+ "Amy", "Kathleen", "Angela", "Shirley", "Brenda", "Emma", "Anna", "Pamela", "Nicole", "Samantha",
35
+ "Katherine", "Christine", "Debra", "Rachel", "Carolyn", "Janet", "Maria", "Heather", "Diane", "Julie",
36
+ "Joyce", "Victoria", "Kelly", "Christina", "Lauren", "Joan", "Evelyn", "Olivia", "Judith", "Megan",
37
+ "Cheryl", "Martha", "Andrea", "Frances", "Hannah", "Jacqueline", "Ann", "Gloria", "Jean", "Kathryn",
38
+ "Alice", "Teresa", "Sara", "Janice", "Doris", "Madison", "Julia", "Grace", "Judy", "Abigail",
39
+ "Marie", "Denise", "Beverly", "Amber", "Theresa", "Marilyn", "Danielle", "Diana", "Brittany", "Natalie",
40
+ "Sophia", "Rose", "Isabella", "Alexis", "Kayla", "Charlotte", "Lori", "Alexandra", "Savannah", "Brooklyn",
41
+ "Bella", "Claire", "Skylar", "Lucy", "Paisley", "Everly", "Caroline", "Nova", "Genesis", "Emilia",
42
+ "Kennedy", "Maya", "Willow", "Kinsley", "Naomi", "Aaliyah", "Elena", "Mckenna", "Ariana", "Allison",
43
+ "Gabriella", "Margot", "Madelyn", "Cora", "Ruby", "Eva", "Serenity", "Autumn", "Adeline", "Hailey",
44
+ "Gianna", "Valentina", "Isla", "Eliana", "Quinn", "Nevaeh", "Ivy", "Sadie", "Piper", "Lydia",
45
+ "Alexa", "Josephine", "Emery", "Phoebe", "Delilah", "Arianna", "Vivian", "Kaylee", "Sophie", "Brielle",
46
+ "Madeline", "Peyton", "Rylee", "Clara", "Hadley", "Melanie", "Mackenzie", "Reagan", "Adalynn", "Liliana",
47
+ "Aubree", "Jade", "Miranda", "Isabelle", "Natalia", "Raelynn", "Dakota", "Athena", "Ximena", "Arya",
48
+ "Leilani", "Taylor", "Faith", "Lena", "Kylie", "Miriam", "Kate", "Summer", "Lyla", "Brooke",
49
+ "Amaya", "Eliza", "Brianna", "Bailey", "Nina", "Khloe", "Jasmine", "Melody", "Isabel", "Norah",
50
+ "Annabelle", "Valeria", "Emerson", "Cecilia", "Valerie", "Molly", "Reese", "Aliyah", "Lilly", "Blair",
51
+ "Finley", "Morgan", "Sydney", "Jordyn", "Eloise", "Trinity", "Daisy", "Catherine", "Lola", "Genevieve",
52
+ "Marley", "Arabella", "Harmony", "Elise", "Remi", "Teagan", "Evie", "London", "Sloane", "Laila",
53
+ "Lucia", "Willa", "Juliana", "Gracie", "June", "Tessa", "Ada", "Camille", "Hope", "Samara",
54
+ "Rosalie", "Ruth", "Fiona", "Georgia", "Noelle", "Vanessa", "Daniela", "Paige", "Violet", "Presley",
55
+ "Adriana", "Joanna", "Giselle", "Harper", "Avery", "Scarlett", "Aria", "Ellie", "Chloe", "Layla",
56
+ ] as const;
package/index.ts CHANGED
@@ -45,7 +45,6 @@ import {
45
45
  renderCall,
46
46
  renderResult,
47
47
  renderResumeCall,
48
- setBroadcastNumberingActive,
49
48
  } from "./render.js";
50
49
  import { runAgentSubprocess, executeParallelSubprocess, type RunningSubagentHandle } from "./runner.js";
51
50
  import {
@@ -166,7 +165,7 @@ const SubagentParams = Type.Object({
166
165
 
167
166
  const ResumeItem = Type.Object({
168
167
  subagent: Type.String({
169
- description: "Unique subagent name returned by a previous subagents run (e.g. code-writer-01)",
168
+ description: "Unique human name returned by a previous subagents run (e.g. John)",
170
169
  }),
171
170
  task: Type.String({
172
171
  description: "New task for the resumed subagent. It keeps its previous context.",
@@ -1001,7 +1000,7 @@ export default function (pi: ExtensionAPI) {
1001
1000
  let pendingResumePlans: ResumableSubagentCall[] = [];
1002
1001
  let modelToRestoreAfterResume: any | undefined;
1003
1002
  const approvedProjectAgentDirsForSession = new Set<string>();
1004
- const activeSubagents = new Map<number, { agent: string; task: string; taskIndex: number; handle: RunningSubagentHandle; name?: string }>();
1003
+ const activeSubagents = new Map<number, { agent: string; task: string; handle: RunningSubagentHandle; name?: string }>();
1005
1004
  /** Names with a resume in flight in this process (same-process race guard). */
1006
1005
  const activeResumeNames = new Set<string>();
1007
1006
  /** Tool calls whose execute result requested an actual Pi error result. */
@@ -1083,9 +1082,10 @@ export default function (pi: ExtensionAPI) {
1083
1082
  const BROADCAST_STEER_PREFIX = "__PI_SUBAGENT_BROADCAST_STEER__";
1084
1083
 
1085
1084
  interface BroadcastTarget {
1085
+ /** Human-name path, e.g. "John > Maria > Elena". */
1086
1086
  display: string;
1087
1087
  topLevelId: number;
1088
- restPath: number[];
1088
+ restPath: string[];
1089
1089
  }
1090
1090
 
1091
1091
  interface ParsedBroadcastSelection {
@@ -1093,83 +1093,40 @@ export default function (pi: ExtensionAPI) {
1093
1093
  errors: string[];
1094
1094
  }
1095
1095
 
1096
- function parseBroadcastPath(raw: string): number[] | null {
1097
- const parts = raw.trim().split(".");
1098
- if (parts.length === 0) return null;
1099
- const path: number[] = [];
1100
- for (const part of parts) {
1101
- if (!/^\d+$/.test(part)) return null;
1102
- const value = Number(part);
1103
- if (!Number.isSafeInteger(value) || value < 1) return null;
1104
- path.push(value);
1096
+ function parseBroadcastSelection(input: string, available: BroadcastTarget[]): ParsedBroadcastSelection {
1097
+ if (input.trim().toUpperCase() === "ALL") return { targets: [...available], errors: [] };
1098
+ const byName = new Map<string, BroadcastTarget>();
1099
+ for (const target of available) {
1100
+ byName.set(target.display.toLowerCase(), target);
1101
+ // Names are globally unique across the delegation tree, so a nested leaf
1102
+ // can be addressed as "Maria" without spelling "John > Maria".
1103
+ const leafName = target.display.split(" > ").at(-1);
1104
+ if (leafName) byName.set(leafName.toLowerCase(), target);
1105
1105
  }
1106
- return path;
1107
- }
1108
-
1109
- function parseBroadcastSelection(input: string, available: number[]): ParsedBroadcastSelection {
1110
- const normalized = input.trim().toUpperCase();
1111
- if (normalized === "ALL") {
1112
- return {
1113
- targets: available.map((id) => ({ display: String(id), topLevelId: id, restPath: [] })),
1114
- errors: [],
1115
- };
1116
- }
1117
-
1106
+ const targets: BroadcastTarget[] = [];
1118
1107
  const errors: string[] = [];
1119
- const targetMap = new Map<string, BroadcastTarget>();
1120
1108
  for (const rawPart of input.split(",")) {
1121
- const part = rawPart.trim();
1122
- if (!part) continue;
1123
-
1124
- if (part.includes("-")) {
1125
- const [startRaw, endRaw, extra] = part.split("-").map((p) => p.trim());
1126
- const startPath = parseBroadcastPath(startRaw);
1127
- const endPath = parseBroadcastPath(endRaw);
1128
- if (extra !== undefined || !startPath || !endPath || startPath.length !== 1 || endPath.length !== 1) {
1129
- errors.push(`Invalid range "${part}". Use top-level ranges like 1-3.`);
1130
- continue;
1131
- }
1132
- const start = startPath[0];
1133
- const end = endPath[0];
1134
- for (let id = Math.min(start, end); id <= Math.max(start, end); id++) {
1135
- if (!available.includes(id)) {
1136
- errors.push(`Subagent ${id} is not running.`);
1137
- continue;
1138
- }
1139
- targetMap.set(String(id), { display: String(id), topLevelId: id, restPath: [] });
1140
- }
1141
- continue;
1142
- }
1143
-
1144
- const path = parseBroadcastPath(part);
1145
- if (!path) {
1146
- errors.push(`Invalid target "${part}". Use ALL, numbers, paths, or top-level ranges (e.g. 1, 2, 4.1, 1-3).`);
1147
- continue;
1148
- }
1149
- const [topLevelId, ...restPath] = path;
1150
- if (!available.includes(topLevelId)) {
1151
- errors.push(`Subagent ${topLevelId} is not running.`);
1152
- continue;
1153
- }
1154
- const display = path.join(".");
1155
- targetMap.set(display, { display, topLevelId, restPath });
1109
+ const requested = rawPart.trim();
1110
+ if (!requested) continue;
1111
+ const target = byName.get(requested.toLowerCase());
1112
+ if (target) targets.push(target);
1113
+ else errors.push(`Subagent "${requested}" is not running. Use its human name.`);
1156
1114
  }
1157
-
1158
1115
  return {
1159
- targets: Array.from(targetMap.values()).sort((a, b) => a.display.localeCompare(b.display, undefined, { numeric: true })),
1116
+ targets: Array.from(new Map(targets.map((target) => [target.display, target])).values()),
1160
1117
  errors,
1161
1118
  };
1162
1119
  }
1163
1120
 
1164
- function encodeNestedBroadcast(message: string, path: number[]): string {
1121
+ function encodeNestedBroadcast(message: string, path: string[]): string {
1165
1122
  return `${BROADCAST_STEER_PREFIX}${JSON.stringify({ path, message })}`;
1166
1123
  }
1167
1124
 
1168
- function decodeNestedBroadcast(text: string): { path: number[]; message: string } | null {
1125
+ function decodeNestedBroadcast(text: string): { path: string[]; message: string } | null {
1169
1126
  if (!text.startsWith(BROADCAST_STEER_PREFIX)) return null;
1170
1127
  try {
1171
1128
  const parsed = JSON.parse(text.slice(BROADCAST_STEER_PREFIX.length));
1172
- if (!Array.isArray(parsed?.path) || !parsed.path.every((n: unknown) => Number.isSafeInteger(n) && (n as number) >= 1)) return null;
1129
+ if (!Array.isArray(parsed?.path) || !parsed.path.every((name: unknown) => typeof name === "string" && name.length > 0)) return null;
1173
1130
  if (typeof parsed?.message !== "string") return null;
1174
1131
  return { path: parsed.path, message: parsed.message };
1175
1132
  } catch {
@@ -1177,66 +1134,37 @@ export default function (pi: ExtensionAPI) {
1177
1134
  }
1178
1135
  }
1179
1136
 
1180
- function extractPendingSubagentTaskCounts(result: SingleResult): number[] {
1181
- const messages = Array.isArray((result as any).messages) ? (result as any).messages : [];
1182
- const completedToolCallIds = new Set(getNestedSubagentResults(messages).map((nested) => nested.toolCallId));
1183
- const counts: number[] = [];
1184
- for (const message of messages) {
1185
- if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
1186
- for (const part of message.content) {
1187
- if (part?.type !== "toolCall" || !isSubagentToolName(part?.name)) continue;
1188
- const toolCallId = typeof part.toolCallId === "string" ? part.toolCallId : typeof part.id === "string" ? part.id : undefined;
1189
- if (toolCallId && completedToolCallIds.has(toolCallId)) continue;
1190
- const rawResumes = part.arguments?.resumes;
1191
- const tasks = Array.isArray(part.arguments?.tasks)
1192
- ? part.arguments.tasks
1193
- : Array.isArray(rawResumes)
1194
- ? rawResumes
1195
- : rawResumes && typeof rawResumes === "object"
1196
- ? [rawResumes]
1197
- : [];
1198
- if (tasks.length > 0) counts.push(tasks.length);
1199
- }
1200
- }
1201
- return counts;
1202
- }
1203
-
1204
1137
  function collectRunningBroadcastTargetsFromResult(
1205
1138
  result: SingleResult,
1206
- path: number[],
1139
+ namePath: string[],
1140
+ topLevelId: number,
1207
1141
  targets: { all: BroadcastTarget[]; youngest: BroadcastTarget[] },
1208
1142
  ): boolean {
1209
- const nestedRunningPaths: number[][] = [];
1210
-
1143
+ let hasRunningDescendant = false;
1144
+ const completedIds = new Set<string>();
1145
+ const nestedDetails: SubagentDetails[] = [];
1211
1146
  for (const nested of getNestedSubagentResults(result.messages)) {
1212
1147
  if (!isSubagentDetails(nested.details)) continue;
1213
- nested.details.results.forEach((child, index) => {
1214
- const childPath = [...path, index + 1];
1215
- if (collectRunningBroadcastTargetsFromResult(child, childPath, targets)) {
1216
- nestedRunningPaths.push(childPath);
1217
- }
1218
- });
1148
+ if (nested.toolCallId) completedIds.add(nested.toolCallId);
1149
+ nestedDetails.push(nested.details);
1219
1150
  }
1220
-
1221
- if (result.exitCode === -1) {
1222
- for (const taskCount of extractPendingSubagentTaskCounts(result)) {
1223
- for (let i = 1; i <= taskCount; i++) {
1224
- const childPath = [...path, i];
1225
- const [topLevelId, ...restPath] = childPath;
1226
- targets.all.push({ display: childPath.join("."), topLevelId, restPath });
1227
- targets.youngest.push({ display: childPath.join("."), topLevelId, restPath });
1228
- nestedRunningPaths.push(childPath);
1151
+ for (const [toolCallId, live] of Object.entries(result.liveNestedSubagents ?? {})) {
1152
+ if (!completedIds.has(toolCallId) && isSubagentDetails(live)) nestedDetails.push(live);
1153
+ }
1154
+ for (const details of nestedDetails) {
1155
+ for (const child of details.results) {
1156
+ const childName = child.name || child.agent;
1157
+ if (collectRunningBroadcastTargetsFromResult(child, [...namePath, childName], topLevelId, targets)) {
1158
+ hasRunningDescendant = true;
1229
1159
  }
1230
1160
  }
1231
1161
  }
1232
1162
 
1233
- const isRunning = result.exitCode === -1;
1234
- if (!isRunning) return nestedRunningPaths.length > 0;
1235
-
1236
- const [topLevelId, ...restPath] = path;
1237
- const self = { display: path.join("."), topLevelId, restPath };
1163
+ if (result.exitCode !== -1) return hasRunningDescendant;
1164
+ const restPath = namePath.slice(1);
1165
+ const self = { display: namePath.join(" > "), topLevelId, restPath };
1238
1166
  targets.all.push(self);
1239
- if (nestedRunningPaths.length === 0) targets.youngest.push(self);
1167
+ if (!hasRunningDescendant) targets.youngest.push(self);
1240
1168
  return true;
1241
1169
  }
1242
1170
 
@@ -1244,28 +1172,34 @@ export default function (pi: ExtensionAPI) {
1244
1172
  latestBroadcastTargets.all = [];
1245
1173
  latestBroadcastTargets.youngest = [];
1246
1174
  if (!details) {
1247
- for (const id of activeSubagents.keys()) {
1248
- const target = { display: String(id), topLevelId: id, restPath: [] };
1175
+ for (const [id, active] of activeSubagents) {
1176
+ const display = active.name || active.agent;
1177
+ const target = { display, topLevelId: id, restPath: [] };
1249
1178
  latestBroadcastTargets.all.push(target);
1250
1179
  latestBroadcastTargets.youngest.push(target);
1251
1180
  }
1252
1181
  return;
1253
1182
  }
1254
1183
  details.results.forEach((result, index) => {
1255
- collectRunningBroadcastTargetsFromResult(result, [topLevelBaseId + index], latestBroadcastTargets);
1184
+ collectRunningBroadcastTargetsFromResult(
1185
+ result,
1186
+ [result.name || result.agent],
1187
+ topLevelBaseId + index,
1188
+ latestBroadcastTargets,
1189
+ );
1256
1190
  });
1257
1191
  const dedupe = (targets: BroadcastTarget[]) =>
1258
1192
  Array.from(new Map(targets.map((target) => [target.display, target])).values())
1259
1193
  .filter((target) => activeSubagents.has(target.topLevelId))
1260
- .sort((a, b) => a.display.localeCompare(b.display, undefined, { numeric: true }));
1194
+ .sort((a, b) => a.display.localeCompare(b.display));
1261
1195
  latestBroadcastTargets.all = dedupe(latestBroadcastTargets.all);
1262
1196
  latestBroadcastTargets.youngest = dedupe(latestBroadcastTargets.youngest);
1263
1197
  }
1264
1198
 
1265
1199
  function getFallbackTopLevelTargets(): BroadcastTarget[] {
1266
- return Array.from(activeSubagents.keys())
1267
- .sort((a, b) => a - b)
1268
- .map((id) => ({ display: String(id), topLevelId: id, restPath: [] }));
1200
+ return Array.from(activeSubagents.entries())
1201
+ .sort(([a], [b]) => a - b)
1202
+ .map(([id, active]) => ({ display: active.name || active.agent, topLevelId: id, restPath: [] }));
1269
1203
  }
1270
1204
 
1271
1205
  function sendBroadcastToTargets(message: string, targets: BroadcastTarget[], ctx: any): void {
@@ -1297,30 +1231,24 @@ export default function (pi: ExtensionAPI) {
1297
1231
  if (nested) {
1298
1232
  if (activeSubagents.size === 0) return "handled";
1299
1233
  const [rawTarget, ...restPath] = nested.path;
1300
- // Path components below the top level are 1-based task indexes within the
1301
- // subagent tool call running in THIS process. Internal active ids keep
1302
- // growing across sequential tool calls (and process restarts), so resolve
1303
- // by task index first; fall back to a direct id match for compatibility.
1304
1234
  let resolvedId: number | undefined;
1305
1235
  for (const [id, item] of activeSubagents) {
1306
- if (item.taskIndex === rawTarget - 1 && (resolvedId === undefined || id > resolvedId)) {
1236
+ if ((item.name || item.agent).toLowerCase() === rawTarget.toLowerCase()) {
1307
1237
  resolvedId = id;
1238
+ break;
1308
1239
  }
1309
1240
  }
1310
- if (resolvedId === undefined && activeSubagents.has(rawTarget)) resolvedId = rawTarget;
1311
1241
  if (resolvedId === undefined) return "handled";
1312
- sendBroadcastToTargets(nested.message, [{ display: nested.path.join("."), topLevelId: resolvedId, restPath }], ctx);
1242
+ sendBroadcastToTargets(nested.message, [{ display: nested.path.join(" > "), topLevelId: resolvedId, restPath }], ctx);
1313
1243
  return "handled";
1314
1244
  }
1315
1245
 
1316
1246
  if (!ctx.hasUI || activeSubagents.size === 0) return "continue";
1317
1247
 
1318
- setBroadcastNumberingActive(true);
1319
1248
  try {
1320
- const available = Array.from(activeSubagents.keys()).sort((a, b) => a - b);
1321
1249
  const choice = await ctx.ui.select(
1322
1250
  "Broadcast this steering message to subagents?",
1323
- ["No", "All (+nested)", "Youngest", "Numbers (e.g. 1, 2, 4.1)"],
1251
+ ["No", "All (+nested)", "Youngest", "Names (e.g. John, Maria)"],
1324
1252
  );
1325
1253
  if (choice === "All (+nested)" || choice === "Youngest") {
1326
1254
  const fallback = getFallbackTopLevelTargets();
@@ -1335,29 +1263,19 @@ export default function (pi: ExtensionAPI) {
1335
1263
  sendBroadcastToTargets(message, current, ctx);
1336
1264
  return "handled";
1337
1265
  }
1338
- if (choice !== "Numbers (e.g. 1, 2, 4.1)") return "continue";
1266
+ if (choice !== "Names (e.g. John, Maria)") return "continue";
1339
1267
 
1340
1268
  const answer = await ctx.ui.input(
1341
- "Subagent numbers/ranges to broadcast to (e.g. 1, 2, 4.1)",
1269
+ "Subagent names to broadcast to (comma-separated)",
1342
1270
  "",
1343
1271
  );
1344
1272
  if (!answer) return "continue";
1345
- const current = Array.from(new Set([
1346
- ...available,
1347
- ...latestBroadcastTargets.all.map((target) => target.topLevelId),
1348
- ])).sort((a, b) => a - b);
1273
+ const current = latestBroadcastTargets.all.length > 0
1274
+ ? latestBroadcastTargets.all
1275
+ : getFallbackTopLevelTargets();
1349
1276
  const parsed = parseBroadcastSelection(answer, current);
1350
- const knownDisplays = new Set(latestBroadcastTargets.all.map((target) => target.display));
1351
- const validatedTargets = knownDisplays.size > 0
1352
- ? parsed.targets.filter((target) => knownDisplays.has(target.display))
1353
- : parsed.targets;
1354
- const unknownNested = knownDisplays.size > 0
1355
- ? parsed.targets.filter((target) => !knownDisplays.has(target.display)).map((target) => target.display)
1356
- : [];
1357
- const errors = [
1358
- ...parsed.errors,
1359
- ...unknownNested.map((display) => `Subagent ${display} is not running.`),
1360
- ];
1277
+ const validatedTargets = parsed.targets;
1278
+ const errors = parsed.errors;
1361
1279
  if (errors.length > 0) {
1362
1280
  ctx.ui.notify(errors.slice(0, 4).join("\n"), "warning");
1363
1281
  }
@@ -1368,7 +1286,7 @@ export default function (pi: ExtensionAPI) {
1368
1286
  sendBroadcastToTargets(message, validatedTargets, ctx);
1369
1287
  return "handled";
1370
1288
  } finally {
1371
- setBroadcastNumberingActive(false);
1289
+ // No persistent steering UI state.
1372
1290
  }
1373
1291
  }
1374
1292
 
@@ -1790,13 +1708,13 @@ ${preventCycles
1790
1708
  ? ""
1791
1709
  : configuredToolPrompts[RESUME_SUBAGENTS_TOOL_NAME] ?? `### Resumable subagents
1792
1710
 
1793
- Every subagent run is assigned a unique, durable name (e.g. \`code-writer-01\`,
1794
- \`code-reviewer-02\`) which is returned together with its results. Use the
1711
+ Every subagent run is assigned a unique, durable human name (e.g. \`John\`,
1712
+ \`Maria\`) which is returned together with its results. Use the
1795
1713
  \`resume_subagents\` tool to continue named subagents with a new task while
1796
1714
  keeping their full previous context:
1797
1715
 
1798
1716
  \`\`\`json
1799
- { "resumes": [{ "subagent": "code-writer-01", "task": "Now also update the tests." }] }
1717
+ { "resumes": [{ "subagent": "John", "task": "Now also update the tests." }] }
1800
1718
  \`\`\`
1801
1719
 
1802
1720
  - \`agent\` (in \`subagents\`) is an agent TYPE; \`subagent\` (in \`resume_subagents\`)
@@ -1937,7 +1855,7 @@ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidan
1937
1855
  pendingResumePlans.splice(resumePlanIndex, 1);
1938
1856
  }
1939
1857
 
1940
- // Assign durable, tree-unique names (code-writer-01, ...). Resumed
1858
+ // Assign random durable human names unique across the whole tree. Resumed
1941
1859
  // runs keep the names already recorded in the previous results.
1942
1860
  const names: Array<string | undefined> = tasks.map(
1943
1861
  (_task, index) => resumePlan?.details?.results[index]?.name,
@@ -1969,7 +1887,7 @@ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidan
1969
1887
  names[index] = allocated[allocIndex];
1970
1888
  });
1971
1889
  } catch (err) {
1972
- console.warn("[pi-subagent] Failed to allocate subagent names (continuing without):", err);
1890
+ throw new Error(`Failed to allocate unique subagent names: ${err instanceof Error ? err.message : String(err)}`);
1973
1891
  }
1974
1892
  }
1975
1893
  }
@@ -2037,10 +1955,10 @@ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidan
2037
1955
  description: configuredToolPrompts[RESUME_SUBAGENTS_TOOL_NAME] ?? [
2038
1956
  "Resume previously run subagents by name with a new task, keeping their full context.",
2039
1957
  "",
2040
- "Every subagent run returns a unique name (e.g. code-writer-01). Pass those names",
1958
+ "Every subagent run returns a unique human name (e.g. John). Pass those names",
2041
1959
  "as `subagent` to continue them. All resumes in one call run IN PARALLEL.",
2042
1960
  "",
2043
- 'Example: { resumes: [{ subagent: "code-writer-01", task: "Also update the tests." }] }',
1961
+ 'Example: { resumes: [{ subagent: "John", task: "Also update the tests." }] }',
2044
1962
  ].join("\n"),
2045
1963
  parameters: ResumeSubagentsParams,
2046
1964
  prepareArguments: prepareResumeArguments,
@@ -2274,7 +2192,9 @@ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidan
2274
2192
  // -----------------------------------------------------------------------
2275
2193
 
2276
2194
  function formatNameSuffix(result: SingleResult | undefined): string {
2277
- return result?.name ? `\n\n(subagent name: ${result.name} — resumable via resume_subagents)` : "";
2195
+ return result?.name
2196
+ ? `\n\n(subagent: ${result.name} (${result.agent}) — resume with the name ${result.name})`
2197
+ : "";
2278
2198
  }
2279
2199
 
2280
2200
  async function executeSingle(
@@ -2326,7 +2246,7 @@ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidan
2326
2246
  fallbackModel,
2327
2247
  onHandle: (handle) => {
2328
2248
  activeId = topLevelBaseId;
2329
- activeSubagents.set(activeId, { agent: agentName, task, taskIndex: 0, handle, name: subagentName });
2249
+ activeSubagents.set(activeId, { agent: agentName, task, handle, name: subagentName });
2330
2250
  updateLatestBroadcastTargets(undefined);
2331
2251
  },
2332
2252
  });
@@ -2345,7 +2265,7 @@ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidan
2345
2265
  content: [
2346
2266
  {
2347
2267
  type: "text" as const,
2348
- text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
2268
+ text: `${result.name ? `${result.name} (${result.agent})` : `Agent ${result.agent}`} ${result.stopReason || "failed"}: ${errorMsg}`,
2349
2269
  },
2350
2270
  ],
2351
2271
  details: makeDetails("single")([result]),
@@ -2398,7 +2318,7 @@ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidan
2398
2318
  (index, task, handle) => {
2399
2319
  const id = topLevelBaseId + index;
2400
2320
  taskIds.set(index, id);
2401
- activeSubagents.set(id, { agent: task.agent, task: task.task, taskIndex: index, handle, name: extras?.names?.[index] });
2321
+ activeSubagents.set(id, { agent: task.agent, task: task.task, handle, name: extras?.names?.[index] });
2402
2322
  updateLatestBroadcastTargets(undefined);
2403
2323
  },
2404
2324
  (index) => {
package/names.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Durable subagent name registry.
3
3
  *
4
- * Every spawned subagent gets a unique, human-friendly name derived from its
5
- * agent type plus a per-type counter (e.g. `code-writer-01`, `code-writer-02`,
6
- * `code-reviwer-01`). Names are unique within one delegation tree (the tree
4
+ * Every spawned subagent gets a random human first name from the bundled
5
+ * 500-name pool (e.g. `John`, `Maria`, `Elena`). Names are unique within one
6
+ * delegation tree (the tree
7
7
  * rooted at the top-level pi session) and are persisted in a JSON registry
8
8
  * file under the subagent session root, so they survive process restarts.
9
9
  *
@@ -20,6 +20,8 @@
20
20
 
21
21
  import * as fs from "node:fs";
22
22
  import * as path from "node:path";
23
+ import { randomInt } from "node:crypto";
24
+ import { AMERICAN_NAMES } from "./american-names.js";
23
25
 
24
26
  export const SUBAGENT_NAMES_FILE_ENV = "PI_SUBAGENT_NAMES_FILE";
25
27
 
@@ -168,7 +170,7 @@ export interface SubagentNameRecord {
168
170
 
169
171
  export interface NamesRegistry {
170
172
  version: 1;
171
- /** Per-agent-type counters used to generate the next name. */
173
+ /** Retained in the file shape for simple migration from older registries. */
172
174
  counters: Record<string, number>;
173
175
  /** All allocated names in this delegation tree. */
174
176
  agents: Record<string, SubagentNameRecord>;
@@ -306,8 +308,8 @@ export function readNamesRegistry(file: string): NamesRegistry {
306
308
  /* fall through to corruption handling */
307
309
  }
308
310
  // Corrupt registry: keep a backup for forensics and warn instead of
309
- // silently resetting (a reset restarts counters, so new names could clash
310
- // with names the model remembers from the conversation transcript).
311
+ // silently resetting (a reset could reuse names the model remembers from
312
+ // the conversation transcript).
311
313
  try {
312
314
  const backup = `${file}.corrupt-${Date.now().toString(36)}`;
313
315
  fs.copyFileSync(file, backup);
@@ -347,8 +349,9 @@ export async function updateNamesRegistry<T>(
347
349
  // Name allocation
348
350
  // ---------------------------------------------------------------------------
349
351
 
350
- export function formatSubagentName(agent: string, index: number): string {
351
- return `${agent}-${String(index).padStart(2, "0")}`;
352
+ export function getAvailableSubagentNames(registry: NamesRegistry): string[] {
353
+ const used = new Set(Object.keys(registry.agents).map((name) => name.toLowerCase()));
354
+ return AMERICAN_NAMES.filter((name) => !used.has(name.toLowerCase()));
352
355
  }
353
356
 
354
357
  export interface AllocateNameRequest {
@@ -371,13 +374,15 @@ export async function allocateSubagentNames(
371
374
  if (requests.length === 0) return [];
372
375
  return updateNamesRegistry(file, (registry) => {
373
376
  const names: string[] = [];
377
+ const available = getAvailableSubagentNames(registry);
378
+ if (available.length < requests.length) {
379
+ throw new Error(
380
+ `The 500-name subagent pool is exhausted (${available.length} available, ${requests.length} requested).`,
381
+ );
382
+ }
374
383
  for (const request of requests) {
375
- let name: string;
376
- do {
377
- const next = (registry.counters[request.agent] ?? 0) + 1;
378
- registry.counters[request.agent] = next;
379
- name = formatSubagentName(request.agent, next);
380
- } while (registry.agents[name] !== undefined);
384
+ const selectedIndex = randomInt(available.length);
385
+ const [name] = available.splice(selectedIndex, 1);
381
386
  registry.agents[name] = {
382
387
  name,
383
388
  agent: request.agent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -11,6 +11,7 @@
11
11
  "runner.ts",
12
12
  "resume.ts",
13
13
  "names.ts",
14
+ "american-names.ts",
14
15
  "shared.ts",
15
16
  "render.ts",
16
17
  "tree.ts",
package/render.ts CHANGED
@@ -7,11 +7,36 @@
7
7
  */
8
8
 
9
9
  import { Container, Spacer, Text } from "@mariozechner/pi-tui";
10
+
11
+ interface Component {
12
+ render(width: number): string[];
13
+ invalidate(): void;
14
+ }
15
+
16
+ function fitLine(text: string, width: number): string {
17
+ if (width <= 0) return "";
18
+ let visible = 0;
19
+ let output = "";
20
+ for (let i = 0; i < text.length && visible < width;) {
21
+ if (text[i] === "\u001b") {
22
+ const match = text.slice(i).match(/^\u001b(?:\[[0-?]*[ -\/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/);
23
+ if (match) {
24
+ output += match[0];
25
+ i += match[0].length;
26
+ continue;
27
+ }
28
+ }
29
+ const codePoint = text.codePointAt(i)!;
30
+ const char = String.fromCodePoint(codePoint);
31
+ output += char;
32
+ i += char.length;
33
+ visible += 1;
34
+ }
35
+ return output;
36
+ }
10
37
  import {
11
38
  MAX_LIVE_LOG_ENTRIES,
12
- isResultError,
13
39
  isSubagentDetails,
14
- type SingleResult,
15
40
  type SubagentDetails,
16
41
  } from "./types.js";
17
42
  import {
@@ -21,6 +46,7 @@ import {
21
46
  formatClockTime,
22
47
  formatLiveLogEntry,
23
48
  hasNestedChildren,
49
+ statusEmoji,
24
50
  renderTreeLines,
25
51
  setBroadcastNumberingActive,
26
52
  topLevelSummary,
@@ -73,19 +99,8 @@ export function renderCall(
73
99
  ): Text {
74
100
  const tasks = Array.isArray(args.tasks) ? args.tasks : [];
75
101
  const count = tasks.length;
76
- const icon = context?.isPartial === false
77
- ? context.isError
78
- ? theme.fg("error", "❌")
79
- : theme.fg("success", "✅")
80
- : theme.fg("warning", "⏳");
81
102
  const stamp = getCallStartStamp(context, theme);
82
- let text = `${stamp}${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `${count} task${count === 1 ? "" : "s"}`)}`;
83
- for (const task of tasks.slice(0, 6)) {
84
- const agent = typeof task?.agent === "string" ? task.agent : "...";
85
- const preview = typeof task?.task === "string" ? ` ${truncate(task.task, 56)}` : "";
86
- text += `\n ${icon} ${theme.fg("accent", agent)}${theme.fg("dim", preview)}`;
87
- }
88
- if (tasks.length > 6) text += `\n ${theme.fg("muted", `... +${tasks.length - 6} more`)}`;
103
+ const text = `${stamp}${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `${count} task${count === 1 ? "" : "s"}`)}`;
89
104
  return new Text(text, 0, 0);
90
105
  }
91
106
 
@@ -104,24 +119,8 @@ export function renderResumeCall(
104
119
  ? [args.resumes]
105
120
  : [];
106
121
  const count = resumes.length;
107
- const icon = context?.isPartial === false
108
- ? context.isError
109
- ? theme.fg("error", "❌")
110
- : theme.fg("success", "✅")
111
- : theme.fg("warning", "⏳");
112
122
  const stamp = getCallStartStamp(context, theme);
113
- let text = `${stamp}${theme.fg("toolTitle", theme.bold("resume subagents "))}${theme.fg("accent", `${count} subagent${count === 1 ? "" : "s"}`)}`;
114
- for (const resume of resumes.slice(0, 6)) {
115
- const name = typeof resume?.subagent === "string"
116
- ? resume.subagent
117
- : typeof resume?.name === "string"
118
- ? resume.name
119
- : "...";
120
- const task = typeof resume?.task === "string" ? resume.task : typeof resume?.prompt === "string" ? resume.prompt : undefined;
121
- const preview = task !== undefined ? ` ${truncate(task, 56)}` : "";
122
- text += `\n ${icon} ${theme.fg("accent", name)}${theme.fg("dim", preview)}`;
123
- }
124
- if (resumes.length > 6) text += `\n ${theme.fg("muted", `... +${resumes.length - 6} more`)}`;
123
+ const text = `${stamp}${theme.fg("toolTitle", theme.bold("resume subagents "))}${theme.fg("accent", `${count} subagent${count === 1 ? "" : "s"}`)}`;
125
124
  return new Text(text, 0, 0);
126
125
  }
127
126
 
@@ -143,52 +142,66 @@ function compactText(text: string, maxLength = 240): string {
143
142
  return truncate(firstLine, maxLength);
144
143
  }
145
144
 
146
- function isRenderableResult(value: unknown): value is SingleResult {
147
- return value !== null &&
148
- typeof value === "object" &&
149
- !Array.isArray(value) &&
150
- typeof (value as Partial<SingleResult>).agent === "string" &&
151
- typeof (value as Partial<SingleResult>).exitCode === "number";
145
+ function takePromptLine(text: string, width: number): { line: string; rest: string } {
146
+ const normalized = text.replace(/\s+/g, " ").trim();
147
+ if (!normalized || width <= 0) return { line: "", rest: normalized };
148
+ if (normalized.length <= width) return { line: normalized, rest: "" };
149
+ let split = normalized.lastIndexOf(" ", width);
150
+ if (split < Math.max(1, Math.floor(width / 2))) split = width;
151
+ return { line: normalized.slice(0, split), rest: normalized.slice(split).trimStart() };
152
152
  }
153
153
 
154
- function getCollapsedResultText(details: SubagentDetails, fallbackText: string): string {
155
- const results = details.results.filter(isRenderableResult);
156
- if (results.length === 0) return compactText(fallbackText);
157
-
158
- const running = results.filter((item) => item.exitCode === -1).length;
159
- const finished = results.length - running;
160
- const succeeded = results.filter((item) => item.exitCode === 0 && !isResultError(item)).length;
161
- const failed = finished - succeeded;
154
+ class CollapsedSubagentComponent implements Component {
155
+ constructor(
156
+ private readonly details: SubagentDetails,
157
+ private readonly theme: { fg: ThemeFg; bold: (s: string) => string },
158
+ ) {}
162
159
 
163
- if (details.mode === "parallel") {
164
- return running > 0
165
- ? `Parallel: ${finished}/${results.length} done, ${running} running...`
166
- : `Parallel: ${succeeded}/${results.length} succeeded${failed > 0 ? `, ${failed} failed` : ""}`;
160
+ render(width: number): string[] {
161
+ if (width <= 0) return [];
162
+ const nodes = buildTopLevelNodes(this.details);
163
+ const lines: string[] = [];
164
+ for (const node of nodes) {
165
+ const rawPrefix = ` ${node.status === "running" ? "⏳" : node.status === "error" ? "❌" : "✅"} ${node.label} `;
166
+ const firstWidth = Math.max(8, width - rawPrefix.length);
167
+ const first = takePromptLine(node.task ?? "", firstWidth);
168
+ const continuationIndent = " ".repeat(Math.min(rawPrefix.length, Math.max(2, width - 8)));
169
+ const second = takePromptLine(first.rest, Math.max(8, width - continuationIndent.length));
170
+ const firstLine = ` ${statusEmoji(node.status, this.theme)} ${this.theme.fg("accent", node.label)}${first.line ? ` ${this.theme.fg("dim", first.line)}` : ""}`;
171
+ lines.push(fitLine(firstLine, width));
172
+ if (first.rest) {
173
+ const hasMore = second.rest.length > 0;
174
+ const secondText = `${second.line}${hasMore ? "..." : ""}`;
175
+ lines.push(fitLine(`${continuationIndent}${this.theme.fg("dim", secondText)}`, width));
176
+ }
177
+ const actionAt = node.lastActionAt ?? node.startedAt;
178
+ if (actionAt !== undefined) {
179
+ lines.push(fitLine(` ${this.theme.fg("muted", `last action: ${formatClockTime(actionAt)}`)}`, width));
180
+ }
181
+ }
182
+ const counts = countNodes(nodes);
183
+ if (lines.length > 0) lines.push("");
184
+ lines.push(fitLine(this.theme.fg("dim", topLevelSummary(this.details, counts)), width));
185
+ return lines;
167
186
  }
168
187
 
169
- const item = results[0];
170
- if (item.exitCode === -1) return `Agent ${item.agent}: running...`;
171
- if (isResultError(item)) {
172
- const reason = [item.errorMessage, item.stderr, item.stopReason]
173
- .find((value): value is string => typeof value === "string" && value.length > 0);
174
- return `Agent ${item.agent}: failed${reason ? ` — ${compactText(reason, 180)}` : ""}`;
175
- }
176
- return `Agent ${item.agent}: completed`;
188
+ invalidate(): void {}
177
189
  }
178
190
 
179
191
  export function renderResult(
180
192
  result: { content: Array<{ type: string; text?: string }>; details?: unknown },
181
193
  expanded: boolean,
182
194
  theme: { fg: ThemeFg; bold: (s: string) => string },
183
- ): Container | Text {
195
+ ): Component | Container | Text {
184
196
  const fallbackText = getResultText(result);
185
197
 
186
- // Keep the normal row compact. Ctrl+O switches to the live tree below.
198
+ // The collapsed view remains live and shows each direct child separately.
199
+ // Its status icon comes from that child's result, not the outer tool state,
200
+ // so completed siblings update while other tasks are still running.
187
201
  if (!expanded) {
188
- const text = isSubagentDetails(result.details)
189
- ? getCollapsedResultText(result.details, fallbackText)
190
- : compactText(fallbackText);
191
- return new Text(text, 0, 0);
202
+ return isSubagentDetails(result.details) && result.details.results.length > 0
203
+ ? new CollapsedSubagentComponent(result.details, theme)
204
+ : new Text(compactText(fallbackText), 0, 0);
192
205
  }
193
206
 
194
207
  if (!isSubagentDetails(result.details) || result.details.results.length === 0) {
@@ -216,7 +229,7 @@ export function renderResult(
216
229
  );
217
230
 
218
231
  container.addChild(new Spacer(1));
219
- container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview).join("\n"), 0, 0));
232
+ container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview, 0, "", true).join("\n"), 0, 0));
220
233
  return container;
221
234
  } catch {
222
235
  // Pi falls back to raw result.content when a custom renderer throws, which
package/runner.ts CHANGED
@@ -150,77 +150,32 @@ function cleanupTempDir(dir: string | null): void {
150
150
  }
151
151
  }
152
152
 
153
- function getCurrentPiCliScript(): string | null {
154
- const script = process.argv[1];
155
- if (!script) return null;
156
-
157
- // When this extension is loaded by pi, process.argv[1] is the pi entrypoint.
158
- // Reusing it with process.execPath avoids relying on PATH while still running
159
- // the exact same pi installation as the parent process.
160
- //
161
- // On Linux/macOS the launched entrypoint is usually the npm `bin` symlink
162
- // (e.g. <prefix>/bin/pi) that points at .../pi-coding-agent/dist/cli.js, so
163
- // argv[1] does NOT end in /dist/cli.js. Resolve symlinks before matching —
164
- // otherwise this check fails and we fall back to scanning PATH, which can
165
- // pick a different (e.g. much slower, cross-filesystem) pi install than the
166
- // one actually running.
167
- const candidates = [script];
168
- try {
169
- const real = fs.realpathSync(script);
170
- if (real && real !== script) candidates.push(real);
171
- } catch {
172
- // argv[1] not statable; fall through with the raw value.
173
- }
174
- for (const candidate of candidates) {
175
- const normalized = candidate.replace(/\\/g, "/");
176
- if (normalized.includes("/pi-coding-agent/") && normalized.endsWith("/dist/cli.js")) {
177
- return candidate;
178
- }
179
- }
180
- return null;
153
+ export interface RuntimeLaunchCommand {
154
+ command: string;
155
+ argsPrefix: string[];
181
156
  }
182
157
 
183
- function findPiCliScriptOnPath(): string | null {
184
- const pathEnv = process.env.PATH ?? "";
185
- for (const dir of pathEnv.split(path.delimiter)) {
186
- if (!dir) continue;
187
- for (const shimName of process.platform === "win32" ? ["pi.cmd", "pi"] : ["pi"]) {
188
- const shimPath = path.join(dir, shimName);
189
- if (!fs.existsSync(shimPath)) continue;
190
-
191
- // Common on Linux/macOS: the npm `bin/pi` entry is a symlink pointing
192
- // straight at .../pi-coding-agent/dist/cli.js. Resolve it directly — its
193
- // file *content* is JS (not a wrapper that names cli.js), so the text
194
- // regex below would miss it and we'd skip this (often faster, same-
195
- // filesystem) install in favour of a later PATH entry.
196
- try {
197
- const real = fs.realpathSync(shimPath);
198
- const normalized = real.replace(/\\/g, "/");
199
- if (normalized.includes("/pi-coding-agent/") && normalized.endsWith("/dist/cli.js")) {
200
- return real;
201
- }
202
- } catch {
203
- // not a resolvable symlink; fall through to the wrapper-text scan.
204
- }
205
-
206
- let text = "";
207
- try {
208
- text = fs.readFileSync(shimPath, "utf8");
209
- } catch {
210
- continue;
211
- }
212
- const match = text.match(/node_modules[\\/]([^\s"']*pi-coding-agent)[\\/]dist[\\/]cli\.js/);
213
- if (!match) continue;
214
- const cliPath = path.join(dir, "node_modules", match[1], "dist", "cli.js");
215
- if (fs.existsSync(cliPath)) return cliPath;
216
- }
158
+ /**
159
+ * Re-launch the runtime and entrypoint that are executing this Pi process.
160
+ *
161
+ * This deliberately knows nothing about npm, pnpm, Pi package names, or dist
162
+ * layouts. With Node it becomes `node <entrypoint>`; with Bun it becomes
163
+ * `bun <entrypoint>`. Package-manager shims have already done their job before
164
+ * the current process starts, so children do not need to execute or parse them.
165
+ */
166
+ export function getCurrentRuntimeLaunch(
167
+ argv: readonly string[] = process.argv,
168
+ execPath = process.execPath,
169
+ ): RuntimeLaunchCommand | null {
170
+ const rawEntrypoint = argv[1];
171
+ if (!execPath || !rawEntrypoint || rawEntrypoint.startsWith("-")) return null;
172
+ const entrypoint = path.resolve(rawEntrypoint);
173
+ try {
174
+ if (!fs.statSync(entrypoint).isFile()) return null;
175
+ } catch {
176
+ return null;
217
177
  }
218
- return null;
219
- }
220
-
221
- /** Resolve the dist/cli.js of the currently running pi installation, if findable. */
222
- export function getPiCliScriptPath(): string | null {
223
- return getCurrentPiCliScript() ?? findPiCliScriptOnPath();
178
+ return { command: execPath, argsPrefix: [entrypoint] };
224
179
  }
225
180
 
226
181
  function getPiSpawnCommand(override?: { command: string; argsPrefix?: string[] }): { command: string; argsPrefix: string[] } {
@@ -243,11 +198,63 @@ function getPiSpawnCommand(override?: { command: string; argsPrefix?: string[] }
243
198
  return { command: overrideCommand, argsPrefix };
244
199
  }
245
200
 
246
- const cliScript = getCurrentPiCliScript() ?? findPiCliScriptOnPath();
247
- if (cliScript) return { command: process.execPath, argsPrefix: [cliScript] };
201
+ const currentRuntime = getCurrentRuntimeLaunch();
202
+ if (currentRuntime) return currentRuntime;
203
+
204
+ // SDK/embedded hosts may have no script entrypoint. They can set the
205
+ // explicit command variables above; keep `pi` as a final conventional
206
+ // fallback for environments where it is a real executable on PATH.
248
207
  return { command: "pi", argsPrefix: [] };
249
208
  }
250
209
 
210
+ /**
211
+ * Build a child environment with a dependable executable search path.
212
+ *
213
+ * Elevated PowerShell sessions and pnpm shims can start Pi with a PATH that
214
+ * omits PNPM_HOME, the user npm bin directory, or even the Node directory.
215
+ * Child Pi processes then start (when we resolved cli.js directly) but cannot
216
+ * launch tools or nested Pi processes. Windows also treats Path/PATH keys
217
+ * case-insensitively, while Node's env object does not, so emit exactly one.
218
+ */
219
+ export function buildChildProcessEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
220
+ const inherited = { ...process.env, ...extra };
221
+ const pathEntries: string[] = [];
222
+ const add = (value: string | undefined) => {
223
+ if (!value) return;
224
+ for (const entry of value.split(path.delimiter)) {
225
+ const clean = entry.trim();
226
+ if (!clean) continue;
227
+ const key = process.platform === "win32" ? clean.toLowerCase() : clean;
228
+ if (!pathEntries.some((existing) => (process.platform === "win32" ? existing.toLowerCase() : existing) === key)) {
229
+ pathEntries.push(clean);
230
+ }
231
+ }
232
+ };
233
+
234
+ for (const [key, value] of Object.entries(inherited)) {
235
+ if (key.toLowerCase() === "path") add(value);
236
+ }
237
+ add(path.dirname(process.execPath));
238
+ add(process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : undefined);
239
+ add(inherited.PNPM_HOME);
240
+ add(inherited.npm_config_prefix);
241
+ if (inherited.npm_config_prefix) add(path.join(inherited.npm_config_prefix, "bin"));
242
+ if (process.platform === "win32") {
243
+ add(inherited.APPDATA ? path.join(inherited.APPDATA, "npm") : undefined);
244
+ add(inherited.LOCALAPPDATA ? path.join(inherited.LOCALAPPDATA, "pnpm") : undefined);
245
+ add(inherited.USERPROFILE ? path.join(inherited.USERPROFILE, "AppData", "Local", "pnpm") : undefined);
246
+ const systemRoot = inherited.SystemRoot ?? inherited.SYSTEMROOT;
247
+ add(systemRoot ? path.join(systemRoot, "System32") : undefined);
248
+ }
249
+
250
+ const env: NodeJS.ProcessEnv = {};
251
+ for (const [key, value] of Object.entries(inherited)) {
252
+ if (key.toLowerCase() !== "path") env[key] = value;
253
+ }
254
+ env[process.platform === "win32" ? "Path" : "PATH"] = pathEntries.join(path.delimiter);
255
+ return env;
256
+ }
257
+
251
258
  function resolveExtensionArg(value: string): string {
252
259
  if (!value) return value;
253
260
  if (value.startsWith("npm:") || value.startsWith("git:")) return value;
@@ -472,6 +479,14 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
472
479
  // Guard: JSON.parse can return null, a number, boolean, or array — none of which have .type
473
480
  if (!event || typeof event !== "object" || Array.isArray(event)) return false;
474
481
 
482
+ const semanticEventTypes = new Set([
483
+ "message_end", "tool_result_end", "subagent_progress", "turn_start",
484
+ "turn_end", "tool_execution_start", "tool_execution_end",
485
+ ]);
486
+ if (semanticEventTypes.has(event.type)) {
487
+ result.lastActionAt = typeof event.timestamp === "number" ? event.timestamp : Date.now();
488
+ }
489
+
475
490
  if (event.type === "message_end" && event.message) {
476
491
  const msg = event.message as Message;
477
492
  if (hasMessage(result, msg)) return true;
@@ -638,7 +653,7 @@ export interface RunAgentOptions {
638
653
  agentName: string;
639
654
  /** Task description. */
640
655
  task: string;
641
- /** Unique resumable name assigned to this subagent (e.g. "code-writer-01"). */
656
+ /** Unique resumable human name assigned to this subagent (e.g. "John"). */
642
657
  subagentName?: string;
643
658
  /** When true, send the task text to the child verbatim (no "Task:" / resume preamble). */
644
659
  rawPrompt?: boolean;
@@ -745,6 +760,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
745
760
  task,
746
761
  name: opts.subagentName ?? initialResult?.name,
747
762
  startedAt: initialResult?.startedAt ?? Date.now(),
763
+ lastActionAt: initialResult?.lastActionAt ?? Date.now(),
748
764
  exitCode: -1,
749
765
  messages: initialResult?.messages ? [...initialResult.messages] : [],
750
766
  stderr: initialResult?.stderr ?? "",
@@ -835,8 +851,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
835
851
  // agents and tools, rather than only their immediate Pi parent.
836
852
  detached: process.platform !== "win32",
837
853
  stdio: ["pipe", "pipe", "pipe"],
838
- env: {
839
- ...process.env,
854
+ env: buildChildProcessEnv({
840
855
  [SUBAGENT_DEPTH_ENV]: String(nextDepth),
841
856
  [SUBAGENT_MAX_DEPTH_ENV]: String(propagatedMaxDepth),
842
857
  [SUBAGENT_STACK_ENV]: JSON.stringify(propagatedStack),
@@ -844,8 +859,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
844
859
  ...(sessionRoot ? { [SUBAGENT_SESSION_ROOT_ENV]: sessionRoot } : {}),
845
860
  ...(opts.namesFile ? { [SUBAGENT_NAMES_FILE_ENV]: opts.namesFile } : {}),
846
861
  ...(fallbackModel ? { [SUBAGENT_FALLBACK_MODEL_ENV]: fallbackModel } : {}),
862
+ // All other provider/auth/proxy/temp/home variables are inherited.
847
863
  // PI_OFFLINE is NOT forced here — see explanation near PI_OFFLINE_ENV.
848
- },
864
+ }),
849
865
  });
850
866
 
851
867
  let buffer = "";
@@ -922,7 +938,10 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
922
938
  const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
923
939
  const killer = spawn(
924
940
  path.join(systemRoot, "System32", "taskkill.exe"),
925
- ["/PID", String(proc.pid), "/T", ...(force ? ["/F"] : [])],
941
+ // taskkill has no reliable graceful console-process mode when
942
+ // launched from elevated PowerShell; without /F it often leaves
943
+ // the Node child alive until our five-second escalation timer.
944
+ ["/PID", String(proc.pid), "/T", "/F"],
926
945
  { shell: false, stdio: "ignore", windowsHide: true },
927
946
  );
928
947
  killer.unref();
@@ -993,7 +1012,11 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
993
1012
  result.stopReason = "error";
994
1013
  result.errorMessage = message;
995
1014
  appendBoundedStderr(result, `[pi-subagent] ${message}\n`);
996
- forceStopAndSettle(1);
1015
+ // No agent turn ever started, so there is no buffered semantic
1016
+ // output to drain. Kill the rejected RPC process tree and settle
1017
+ // immediately instead of waiting on slow Windows taskkill/stdio.
1018
+ stopChild(true);
1019
+ doResolve(1);
997
1020
  }
998
1021
  return;
999
1022
  }
@@ -1002,6 +1025,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1002
1025
  // compact-and-retry, or process a queued continuation. Killing here was
1003
1026
  // the direct cause of the WebSocket failures in the inspected session.
1004
1027
  if (event?.type === "agent_end") {
1028
+ result.lastActionAt = Date.now();
1005
1029
  noteSemanticActivity(
1006
1030
  event.willRetry === true ? RETRY_WAIT_GRACE_MS : 0,
1007
1031
  );
@@ -1010,6 +1034,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1010
1034
 
1011
1035
  if (event?.type === "agent_settled") {
1012
1036
  agentSettled = true;
1037
+ result.lastActionAt = Date.now();
1013
1038
  // No semantic watchdog may fire while we are only waiting for the
1014
1039
  // deliberately terminated RPC process tree to close. On slower
1015
1040
  // launchers (notably Windows taskkill), that cleanup can outlast a
@@ -1052,8 +1077,10 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1052
1077
  if (event?.type === "message_update" || event?.type === "tool_execution_update") {
1053
1078
  // Streaming deltas are intentionally not retained in result.messages,
1054
1079
  // but they prove the model/tool is still making real progress.
1080
+ result.lastActionAt = Date.now();
1055
1081
  noteSemanticActivity();
1056
1082
  } else if (event?.type === "auto_retry_start") {
1083
+ result.lastActionAt = Date.now();
1057
1084
  const delayMs = Number.isFinite(event.delayMs) ? Math.max(0, Number(event.delayMs)) : 0;
1058
1085
  noteSemanticActivity(delayMs + RETRY_WAIT_GRACE_MS);
1059
1086
  } else if (
@@ -1064,6 +1091,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1064
1091
  event?.type === "summarization_retry_attempt_start" ||
1065
1092
  event?.type === "summarization_retry_finished"
1066
1093
  ) {
1094
+ result.lastActionAt = Date.now();
1067
1095
  noteSemanticActivity();
1068
1096
  }
1069
1097
  }
@@ -1340,6 +1368,7 @@ export async function executeParallelSubprocess(
1340
1368
  task: t.task,
1341
1369
  name: extras?.names?.[index],
1342
1370
  startedAt: Date.now(),
1371
+ lastActionAt: Date.now(),
1343
1372
  exitCode: -1,
1344
1373
  messages: [],
1345
1374
  stderr: "",
@@ -1434,9 +1463,9 @@ export async function executeParallelSubprocess(
1434
1463
  const output = succeeded
1435
1464
  ? getFinalOutput(r.messages, r.finalOutput)
1436
1465
  : r.errorMessage || r.stderr || getFinalOutput(r.messages, r.finalOutput);
1437
- const nameTag = r.name ? ` • name: ${r.name}` : "";
1466
+ const identity = r.name ? `${r.name} (${r.agent})` : r.agent;
1438
1467
  const status = succeeded ? "completed" : r.exitCode === -1 ? "unfinished" : "failed";
1439
- return `[${r.agent}${nameTag}] ${status}: ${output || "(no output)"}`;
1468
+ return `[${identity}] ${status}: ${output || "(no output)"}`;
1440
1469
  });
1441
1470
 
1442
1471
  return {
package/tree.ts CHANGED
@@ -44,6 +44,8 @@ export interface TreeNode {
44
44
  task?: string;
45
45
  /** Epoch ms when this subagent run started (rendered as a dim hh:mm:ss prefix). */
46
46
  startedAt?: number;
47
+ /** Latest activity across this node and every descendant. */
48
+ lastActionAt?: number;
47
49
  outputPreview?: string[];
48
50
  liveActivity?: LiveLogEntry[];
49
51
  children: TreeNode[];
@@ -571,7 +573,6 @@ function buildResultNode(rawResult: SingleResult): TreeNode {
571
573
  const status = statusFromResult(result);
572
574
  const usage = formatUsage(result.usage, result.model);
573
575
  const metaParts: string[] = [];
574
- if (typeof result.name === "string" && result.name) metaParts.push(result.name);
575
576
  if (typeof result.agentSource === "string" && result.agentSource) metaParts.push(result.agentSource);
576
577
  if (usage) metaParts.push(usage);
577
578
  if (status === "error") {
@@ -584,14 +585,21 @@ function buildResultNode(rawResult: SingleResult): TreeNode {
584
585
  const children = buildNestedChildren(result);
585
586
  const isRunning = status === "running";
586
587
  const liveLog = Array.isArray(result.liveLog) ? result.liveLog : [];
588
+ const ownLastAction = typeof result.lastActionAt === "number" && Number.isFinite(result.lastActionAt)
589
+ ? result.lastActionAt
590
+ : liveLog.reduce((latest, entry) => Math.max(latest, typeof entry.at === "number" ? entry.at : 0), result.startedAt ?? 0);
591
+ const descendantLastAction = children.reduce((latest, child) => Math.max(latest, child.lastActionAt ?? 0), 0);
592
+ const agentType = stringValue(result.agent, "unknown agent");
593
+ const humanName = typeof result.name === "string" && result.name ? result.name : undefined;
587
594
  return {
588
- label: stringValue(result.agent, "unknown agent"),
595
+ label: humanName ? `${humanName} (${agentType})` : agentType,
589
596
  status,
590
597
  meta: metaParts.join(" • "),
591
598
  task: typeof result.task === "string" ? result.task : undefined,
592
599
  startedAt: typeof result.startedAt === "number" && Number.isFinite(result.startedAt)
593
600
  ? result.startedAt
594
601
  : undefined,
602
+ lastActionAt: Math.max(ownLastAction, descendantLastAction) || undefined,
595
603
  liveActivity: isRunning && liveLog.length > 0 ? liveLog.slice(-MAX_LIVE_LOG_ENTRIES) : undefined,
596
604
  outputPreview: !isRunning && children.length === 0 ? buildLeafPreview(result) : undefined,
597
605
  children,
@@ -608,6 +616,7 @@ export function renderTreeLines(
608
616
  showOutputPreview: boolean,
609
617
  depth = 0,
610
618
  prefix = "",
619
+ showFullPrompts = false,
611
620
  ): string[] {
612
621
  const lines: string[] = [];
613
622
 
@@ -622,6 +631,14 @@ export function renderTreeLines(
622
631
  if (node.meta) line += ` ${theme.fg("dim", node.meta)}`;
623
632
  lines.push(line);
624
633
 
634
+ if (showFullPrompts && node.task) {
635
+ const promptLines = node.task.replace(/\r\n?/g, "\n").split("\n");
636
+ promptLines.forEach((promptLine, promptIndex) => {
637
+ const promptLabel = promptIndex === 0 ? "prompt: " : " ";
638
+ lines.push(`${indent} ${theme.fg("muted", promptLabel)}${theme.fg("toolOutput", promptLine)}`);
639
+ });
640
+ }
641
+
625
642
  if (showOutputPreview && node.outputPreview && node.outputPreview.length > 0) {
626
643
  for (const outputLine of node.outputPreview) {
627
644
  lines.push(`${indent} ${theme.fg("toolOutput", outputLine)}`);
@@ -641,7 +658,7 @@ export function renderTreeLines(
641
658
  }
642
659
 
643
660
  if (node.children.length > 0) {
644
- lines.push(...renderTreeLines(node.children, theme, showOutputPreview, depth + 1, number));
661
+ lines.push(...renderTreeLines(node.children, theme, showOutputPreview, depth + 1, number, showFullPrompts));
645
662
  }
646
663
  });
647
664
 
package/types.ts CHANGED
@@ -64,10 +64,12 @@ export interface SingleResult {
64
64
  agent: string;
65
65
  agentSource: "user" | "project" | "builtin" | "unknown";
66
66
  task: string;
67
- /** Unique resumable name for this subagent within the delegation tree (e.g. "code-writer-01"). */
67
+ /** Unique resumable human name within the delegation tree (e.g. "John"). */
68
68
  name?: string;
69
69
  /** Epoch ms when this subagent run started (used for TUI timestamps). */
70
70
  startedAt?: number;
71
+ /** Most recent semantic activity in this agent's entire live run. */
72
+ lastActionAt?: number;
71
73
  exitCode: number;
72
74
  messages: Message[];
73
75
  stderr: string;
@@ -264,6 +266,7 @@ export function compactSingleResultForDurableDetails(result: SingleResult): Sing
264
266
  subtreeUsageSummary: result.subtreeUsageSummary ?? buildUsageSummary([result]),
265
267
  ...(result.name !== undefined ? { name: result.name } : {}),
266
268
  ...(result.startedAt !== undefined ? { startedAt: result.startedAt } : {}),
269
+ ...(result.lastActionAt !== undefined ? { lastActionAt: result.lastActionAt } : {}),
267
270
  ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}),
268
271
  ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
269
272
  ...(result.model !== undefined ? { model: result.model } : {}),