@narumitw/pi-subagents 0.43.0 → 0.43.1

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/src/render.ts CHANGED
@@ -1,4 +1,3 @@
1
- import * as os from "node:os";
2
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
2
  import type { Message } from "@earendil-works/pi-ai";
4
3
  import {
@@ -10,6 +9,7 @@ import {
10
9
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
11
10
  import type { AgentScope, SubagentThinkingLevel } from "./agents.js";
12
11
  import { hasUsableAggregator, type SubagentParams } from "./params.js";
12
+ import { expansionHint, formatToolActivity, safeBlock, safeLine } from "./render-common.js";
13
13
  import {
14
14
  getResultFinalOutput,
15
15
  isResultError,
@@ -17,15 +17,15 @@ import {
17
17
  type SubagentDetails,
18
18
  } from "./runner.js";
19
19
 
20
- const COLLAPSED_ITEM_COUNT = 10;
20
+ const COLLAPSED_ITEM_COUNT = 5;
21
21
 
22
22
  function previewTask(task: unknown, maxLength = 40): string {
23
- if (typeof task !== "string" || task.length === 0) return "...";
24
- return task.length > maxLength ? `${task.slice(0, maxLength)}...` : task;
23
+ const safe = safeLine(task, "...", 2 * 1024);
24
+ return safe.length > maxLength ? `${safe.slice(0, maxLength)}...` : safe;
25
25
  }
26
26
 
27
27
  function previewAgent(agent: unknown): string {
28
- return typeof agent === "string" && agent.length > 0 ? agent : "...";
28
+ return safeLine(agent, "...", 256);
29
29
  }
30
30
 
31
31
  export function formatTokens(count: number): string {
@@ -59,12 +59,12 @@ export function formatUsageStats(
59
59
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
60
60
  if (usage.contextTokens && usage.contextTokens > 0)
61
61
  parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
62
+ const safeProvider = actualProvider ? safeLine(actualProvider, "", 256) : undefined;
63
+ const safeModel = actualModel ? safeLine(actualModel, "", 256) : undefined;
62
64
  const actual =
63
- actualProvider && actualModel
64
- ? `${actualProvider}/${actualModel}`
65
- : (actualModel ?? actualProvider);
66
- if (actual ?? model) parts.push(actual ?? model ?? "");
67
- if (thinkingLevel) parts.push(`requested-thinking:${thinkingLevel}`);
65
+ safeProvider && safeModel ? `${safeProvider}/${safeModel}` : (safeModel ?? safeProvider);
66
+ if (actual ?? model) parts.push(actual ?? safeLine(model, "", 256));
67
+ if (thinkingLevel) parts.push(`requested-thinking:${safeLine(thinkingLevel, "", 128)}`);
68
68
  return parts.join(" ");
69
69
  }
70
70
 
@@ -83,71 +83,7 @@ function formatToolCall(
83
83
  args: Record<string, unknown>,
84
84
  themeFg: (color: ThemeColor, text: string) => string,
85
85
  ): string {
86
- const shortenPath = (p: string) => {
87
- const home = os.homedir();
88
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
89
- };
90
-
91
- switch (toolName) {
92
- case "bash": {
93
- const command = (args.command as string) || "...";
94
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
95
- return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
96
- }
97
- case "read": {
98
- const rawPath = (args.file_path || args.path || "...") as string;
99
- const filePath = shortenPath(rawPath);
100
- const offset = args.offset as number | undefined;
101
- const limit = args.limit as number | undefined;
102
- let text = themeFg("accent", filePath);
103
- if (offset !== undefined || limit !== undefined) {
104
- const startLine = offset ?? 1;
105
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
106
- text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
107
- }
108
- return themeFg("muted", "read ") + text;
109
- }
110
- case "write": {
111
- const rawPath = (args.file_path || args.path || "...") as string;
112
- const filePath = shortenPath(rawPath);
113
- const content = (args.content || "") as string;
114
- const lines = content.split("\n").length;
115
- let text = themeFg("muted", "write ") + themeFg("accent", filePath);
116
- if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
117
- return text;
118
- }
119
- case "edit": {
120
- const rawPath = (args.file_path || args.path || "...") as string;
121
- return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
122
- }
123
- case "ls": {
124
- const rawPath = (args.path || ".") as string;
125
- return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
126
- }
127
- case "find": {
128
- const pattern = (args.pattern || "*") as string;
129
- const rawPath = (args.path || ".") as string;
130
- return (
131
- themeFg("muted", "find ") +
132
- themeFg("accent", pattern) +
133
- themeFg("dim", ` in ${shortenPath(rawPath)}`)
134
- );
135
- }
136
- case "grep": {
137
- const pattern = (args.pattern || "") as string;
138
- const rawPath = (args.path || ".") as string;
139
- return (
140
- themeFg("muted", "grep ") +
141
- themeFg("accent", `/${pattern}/`) +
142
- themeFg("dim", ` in ${shortenPath(rawPath)}`)
143
- );
144
- }
145
- default: {
146
- const argsStr = JSON.stringify(args);
147
- const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
148
- return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
149
- }
150
- }
86
+ return formatToolActivity(toolName, args, themeFg);
151
87
  }
152
88
 
153
89
  type DisplayItem =
@@ -160,10 +96,14 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
160
96
  if (msg.role === "assistant") {
161
97
  for (const part of msg.content) {
162
98
  if (part.type === "text") {
163
- const text = part.text.trim();
99
+ const text = safeBlock(part.text, "", 8 * 1024).trim();
164
100
  if (text) items.push({ type: "text", text });
165
101
  } else if (part.type === "toolCall") {
166
- items.push({ type: "toolCall", name: part.name, args: part.arguments });
102
+ items.push({
103
+ type: "toolCall",
104
+ name: safeLine(part.name, "tool", 256),
105
+ args: part.arguments,
106
+ });
167
107
  }
168
108
  }
169
109
  }
@@ -181,6 +121,47 @@ function getCollapsedDisplayItems(result: SingleResult): { items: DisplayItem[];
181
121
  return { items, total: items.length };
182
122
  }
183
123
 
124
+ function sanitizeSingleResultForRender(result: SingleResult): SingleResult {
125
+ return {
126
+ ...result,
127
+ agent: safeLine(result.agent, "subagent", 256),
128
+ task: safeBlock(result.task, "", 50 * 1024),
129
+ stderr: safeBlock(result.stderr, "", 8 * 1024),
130
+ model: result.model ? safeLine(result.model, "", 256) : undefined,
131
+ actualProvider: result.actualProvider ? safeLine(result.actualProvider, "", 256) : undefined,
132
+ actualModel: result.actualModel ? safeLine(result.actualModel, "", 256) : undefined,
133
+ stopReason: result.stopReason ? safeLine(result.stopReason, "", 256) : undefined,
134
+ errorMessage: result.errorMessage ? safeBlock(result.errorMessage, "", 8 * 1024) : undefined,
135
+ finalOutput: result.finalOutput ? safeBlock(result.finalOutput, "", 50 * 1024) : undefined,
136
+ };
137
+ }
138
+
139
+ function renderResultStatus(result: SingleResult, isPartial: boolean): string {
140
+ if (isResultError(result)) {
141
+ return result.stopReason === "aborted" || result.aborted ? "Cancelled" : "Failed";
142
+ }
143
+ return isPartial || result.exitCode === -1 ? "Running" : "Completed";
144
+ }
145
+
146
+ function coloredStatus(theme: Theme, status: string): string {
147
+ const color: ThemeColor =
148
+ status === "Failed" ? "error" : status === "Completed" ? "success" : "warning";
149
+ return theme.fg(color, status);
150
+ }
151
+
152
+ function coloredResultStatus(theme: Theme, result: SingleResult, isPartial: boolean): string {
153
+ return coloredStatus(theme, renderResultStatus(result, isPartial));
154
+ }
155
+
156
+ function formatResultPolicy(result: SingleResult): string {
157
+ if (!result.policy) return "";
158
+ return [
159
+ `inherited: ${result.policy.inherited.map((tool) => safeLine(tool, "", 256)).join(", ") || "none"}`,
160
+ `overridden: ${result.policy.overridden.map((tool) => safeLine(tool, "", 256)).join(", ") || "none"}`,
161
+ `unsupported: ${result.policy.unsupported.map((tool) => safeLine(tool, "", 256)).join(", ") || "none"}`,
162
+ ].join("\n");
163
+ }
164
+
184
165
  export function renderSubagentCall(args: SubagentParams, theme: Theme) {
185
166
  const scope: AgentScope = args.agentScope ?? "user";
186
167
  if (args.chain && args.chain.length > 0) {
@@ -239,11 +220,22 @@ export function renderSubagentResult(
239
220
  { expanded, isPartial }: ToolRenderResultOptions,
240
221
  theme: Theme,
241
222
  ) {
242
- const details = result.details as SubagentDetails | undefined;
243
- if (!details || details.results.length === 0) {
223
+ const rawDetails = result.details as SubagentDetails | undefined;
224
+ if (!rawDetails || rawDetails.results.length === 0) {
244
225
  const text = result.content[0];
245
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
226
+ return new Text(
227
+ text?.type === "text" ? safeBlock(text.text, "(no output)", 8 * 1024) : "(no output)",
228
+ 0,
229
+ 0,
230
+ );
246
231
  }
232
+ const details: SubagentDetails = {
233
+ ...rawDetails,
234
+ results: rawDetails.results.map(sanitizeSingleResultForRender),
235
+ aggregator: rawDetails.aggregator
236
+ ? sanitizeSingleResultForRender(rawDetails.aggregator)
237
+ : undefined,
238
+ };
247
239
 
248
240
  const mdTheme = getMarkdownTheme();
249
241
 
@@ -254,7 +246,8 @@ export function renderSubagentResult(
254
246
  if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
255
247
  for (const item of toShow) {
256
248
  if (item.type === "text") {
257
- const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
249
+ const safeText = safeBlock(item.text, "", 8 * 1024);
250
+ const preview = expanded ? safeText : safeText.split("\n").slice(0, 3).join("\n");
258
251
  text += `${theme.fg("toolOutput", preview)}\n`;
259
252
  } else {
260
253
  text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
@@ -276,7 +269,7 @@ export function renderSubagentResult(
276
269
 
277
270
  if (expanded) {
278
271
  const container = new Container();
279
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
272
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${safeLine(r.agentSource, "unknown", 128)})`)} · ${coloredResultStatus(theme, r, isPartial)}`;
280
273
  if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
281
274
  container.addChild(new Text(header, 0, 0));
282
275
  if (isError && r.errorMessage)
@@ -284,6 +277,12 @@ export function renderSubagentResult(
284
277
  container.addChild(new Spacer(1));
285
278
  container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
286
279
  container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
280
+ const policy = formatResultPolicy(r);
281
+ if (policy) {
282
+ container.addChild(new Spacer(1));
283
+ container.addChild(new Text(theme.fg("muted", "─── Policy ───"), 0, 0));
284
+ container.addChild(new Text(theme.fg("dim", policy), 0, 0));
285
+ }
287
286
  container.addChild(new Spacer(1));
288
287
  container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
289
288
  if (displayItems.length === 0 && !finalOutput) {
@@ -314,15 +313,16 @@ export function renderSubagentResult(
314
313
  }
315
314
 
316
315
  const collapsed = getCollapsedDisplayItems(r);
317
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
316
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${safeLine(r.agentSource, "unknown", 128)})`)} · ${coloredResultStatus(theme, r, isPartial)}`;
318
317
  if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
319
318
  if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
320
319
  if (collapsed.items.length > 0) {
321
320
  text += `\n${renderDisplayItems(collapsed.items, COLLAPSED_ITEM_COUNT, collapsed.total)}`;
322
- if (collapsed.total > COLLAPSED_ITEM_COUNT)
323
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
321
+ if (collapsed.total > COLLAPSED_ITEM_COUNT) text += `\n${expansionHint()}`;
324
322
  } else if (finalOutput.trim()) {
325
- text += `\n${theme.fg("toolOutput", finalOutput.trim().split("\n").slice(0, 3).join("\n"))}`;
323
+ const outputLines = finalOutput.trim().split("\n");
324
+ text += `\n${theme.fg("toolOutput", outputLines.slice(0, 3).join("\n"))}`;
325
+ if (outputLines.length > 3) text += `\n${expansionHint()}`;
326
326
  } else if (!isError || !r.errorMessage) {
327
327
  text += `\n${theme.fg("muted", isPartial && !isError ? "(running...)" : "(no output)")}`;
328
328
  }
@@ -356,6 +356,11 @@ export function renderSubagentResult(
356
356
  : successCount === details.results.length
357
357
  ? theme.fg("success", "✓")
358
358
  : theme.fg("error", "✗");
359
+ const overallStatus = currentIsRunning
360
+ ? "Running"
361
+ : successCount === details.results.length
362
+ ? "Completed"
363
+ : "Failed";
359
364
 
360
365
  if (expanded) {
361
366
  const container = new Container();
@@ -364,7 +369,8 @@ export function renderSubagentResult(
364
369
  icon +
365
370
  " " +
366
371
  theme.fg("toolTitle", theme.bold("chain ")) +
367
- theme.fg("accent", `${successCount}/${details.results.length} steps`),
372
+ theme.fg("accent", `${successCount}/${details.results.length} steps`) +
373
+ ` · ${coloredStatus(theme, overallStatus)}`,
368
374
  0,
369
375
  0,
370
376
  ),
@@ -383,12 +389,17 @@ export function renderSubagentResult(
383
389
  container.addChild(new Spacer(1));
384
390
  container.addChild(
385
391
  new Text(
386
- `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
392
+ `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, currentIsRunning && r === currentResult)}`,
387
393
  0,
388
394
  0,
389
395
  ),
390
396
  );
391
397
  container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
398
+ const policy = formatResultPolicy(r);
399
+ if (policy)
400
+ container.addChild(
401
+ new Text(theme.fg("muted", "Policy:\n") + theme.fg("dim", policy), 0, 0),
402
+ );
392
403
  if (rFailed && r.errorMessage)
393
404
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
394
405
 
@@ -429,7 +440,8 @@ export function renderSubagentResult(
429
440
  icon +
430
441
  " " +
431
442
  theme.fg("toolTitle", theme.bold("chain ")) +
432
- theme.fg("accent", `${successCount}/${details.results.length} steps`);
443
+ theme.fg("accent", `${successCount}/${details.results.length} steps`) +
444
+ ` · ${coloredStatus(theme, overallStatus)}`;
433
445
  for (const r of details.results) {
434
446
  const rFailed = isResultError(r);
435
447
  const rIcon = rFailed
@@ -439,7 +451,7 @@ export function renderSubagentResult(
439
451
  : theme.fg("success", "✓");
440
452
  const collapsed = getCollapsedDisplayItems(r);
441
453
  const finalOutput = getResultFinalOutput(r).trim();
442
- text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
454
+ text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, currentIsRunning && r === currentResult)}`;
443
455
  if (rFailed && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
444
456
  if (collapsed.items.length > 0)
445
457
  text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
@@ -451,7 +463,7 @@ export function renderSubagentResult(
451
463
  }
452
464
  const usageStr = formatUsageStats(aggregateUsage(details.results));
453
465
  if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
454
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
466
+ text += `\n${expansionHint()}`;
455
467
  return new Text(text, 0, 0);
456
468
  }
457
469
 
@@ -485,12 +497,17 @@ export function renderSubagentResult(
485
497
  : aggregator
486
498
  ? `${successCount}/${details.results.length} tasks + fan-in`
487
499
  : `${successCount}/${details.results.length} tasks`;
500
+ const overallStatus = isRunning
501
+ ? "Running"
502
+ : failCount > 0 || aggregatorFailed
503
+ ? "Failed"
504
+ : "Completed";
488
505
 
489
506
  if (expanded && !isRunning) {
490
507
  const container = new Container();
491
508
  container.addChild(
492
509
  new Text(
493
- `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
510
+ `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)} · ${coloredStatus(theme, overallStatus)}`,
494
511
  0,
495
512
  0,
496
513
  ),
@@ -508,9 +525,18 @@ export function renderSubagentResult(
508
525
 
509
526
  container.addChild(new Spacer(1));
510
527
  container.addChild(
511
- new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
528
+ new Text(
529
+ `${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, resultIsRunning(r))}`,
530
+ 0,
531
+ 0,
532
+ ),
512
533
  );
513
534
  container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
535
+ const policy = formatResultPolicy(r);
536
+ if (policy)
537
+ container.addChild(
538
+ new Text(theme.fg("muted", "Policy:\n") + theme.fg("dim", policy), 0, 0),
539
+ );
514
540
  if (rFailed && r.errorMessage)
515
541
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
516
542
 
@@ -550,7 +576,7 @@ export function renderSubagentResult(
550
576
  container.addChild(new Spacer(1));
551
577
  container.addChild(
552
578
  new Text(
553
- `${theme.fg("muted", "─── fan-in → ") + theme.fg("accent", aggregator.agent)} ${rIcon}`,
579
+ `${theme.fg("muted", "─── fan-in → ") + theme.fg("accent", aggregator.agent)} ${rIcon} ${coloredResultStatus(theme, aggregator, aggregatorRunning)}`,
554
580
  0,
555
581
  0,
556
582
  ),
@@ -558,6 +584,11 @@ export function renderSubagentResult(
558
584
  container.addChild(
559
585
  new Text(theme.fg("muted", "Task: ") + theme.fg("dim", aggregator.task), 0, 0),
560
586
  );
587
+ const aggregatorPolicy = formatResultPolicy(aggregator);
588
+ if (aggregatorPolicy)
589
+ container.addChild(
590
+ new Text(theme.fg("muted", "Policy:\n") + theme.fg("dim", aggregatorPolicy), 0, 0),
591
+ );
561
592
  if (aggregatorFailed && aggregator.errorMessage)
562
593
  container.addChild(
563
594
  new Text(theme.fg("error", `Error: ${aggregator.errorMessage}`), 0, 0),
@@ -592,7 +623,7 @@ export function renderSubagentResult(
592
623
  }
593
624
 
594
625
  // Collapsed view (or still running)
595
- let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
626
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)} · ${coloredStatus(theme, overallStatus)}`;
596
627
  for (const r of details.results) {
597
628
  const rFailed = isResultError(r);
598
629
  const rRunning = resultIsRunning(r);
@@ -603,7 +634,7 @@ export function renderSubagentResult(
603
634
  : theme.fg("success", "✓");
604
635
  const collapsed = getCollapsedDisplayItems(r);
605
636
  const finalOutput = getResultFinalOutput(r).trim();
606
- text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
637
+ text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, rRunning)}`;
607
638
  if (rFailed && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
608
639
  if (collapsed.items.length > 0)
609
640
  text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
@@ -620,7 +651,7 @@ export function renderSubagentResult(
620
651
  : theme.fg("success", "✓");
621
652
  const collapsed = getCollapsedDisplayItems(aggregator);
622
653
  const finalOutput = getResultFinalOutput(aggregator).trim();
623
- text += `\n\n${theme.fg("muted", "─── fan-in → ")}${theme.fg("accent", aggregator.agent)} ${rIcon}`;
654
+ text += `\n\n${theme.fg("muted", "─── fan-in → ")}${theme.fg("accent", aggregator.agent)} ${rIcon} ${coloredResultStatus(theme, aggregator, aggregatorRunning)}`;
624
655
  if (aggregatorFailed && aggregator.errorMessage)
625
656
  text += `\n${theme.fg("error", `Error: ${aggregator.errorMessage}`)}`;
626
657
  if (collapsed.items.length > 0)
@@ -636,10 +667,14 @@ export function renderSubagentResult(
636
667
  const usageStr = formatUsageStats(aggregateUsage(usageResults));
637
668
  if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
638
669
  }
639
- if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
670
+ if (!expanded) text += `\n${expansionHint()}`;
640
671
  return new Text(text, 0, 0);
641
672
  }
642
673
 
643
674
  const text = result.content[0];
644
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
675
+ return new Text(
676
+ text?.type === "text" ? safeBlock(text.text, "(no output)", 8 * 1024) : "(no output)",
677
+ 0,
678
+ 0,
679
+ );
645
680
  }
package/src/runner.ts CHANGED
@@ -6,6 +6,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
6
6
  import type { Message } from "@earendil-works/pi-ai";
7
7
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
8
8
  import type { AgentConfig, AgentScope, AgentSource, SubagentThinkingLevel } from "./agents.js";
9
+ import type { TargetPolicyAudit } from "./cwd-policy.js";
9
10
  import {
10
11
  appendBounded,
11
12
  DEFAULT_MAX_CONTEXT_BYTES,
@@ -81,6 +82,7 @@ export interface SingleResult {
81
82
  malformedEvents?: number;
82
83
  launchFailed?: boolean;
83
84
  processStarted?: boolean;
85
+ target?: TargetPolicyAudit;
84
86
  policy?: {
85
87
  inherited: string[];
86
88
  overridden: string[];
package/src/settings.ts CHANGED
@@ -6,8 +6,12 @@ import lockfile from "proper-lockfile";
6
6
  import {
7
7
  type AgentConfig,
8
8
  CONSULT_RESOURCE_POLICIES,
9
+ CONSULTATION_CWD_POLICIES,
9
10
  type CompletionDelivery,
11
+ type ConsultationCwdPolicy,
10
12
  type ConsultResourcePolicy,
13
+ DELEGATION_CWD_POLICIES,
14
+ type DelegationCwdPolicy,
11
15
  isThinkingLevel,
12
16
  type SubagentAgentConfig,
13
17
  type SubagentSettings,
@@ -158,6 +162,29 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
158
162
  }
159
163
  settings.consult = consult;
160
164
  }
165
+ if (hasOwn(value, "cwdPolicy")) {
166
+ if (!isPlainObject(value.cwdPolicy)) return undefined;
167
+ const cwdPolicy: NonNullable<SubagentSettings["cwdPolicy"]> = {};
168
+ if (hasOwn(value.cwdPolicy, "consultation")) {
169
+ if (
170
+ typeof value.cwdPolicy.consultation !== "string" ||
171
+ !CONSULTATION_CWD_POLICIES.includes(value.cwdPolicy.consultation as ConsultationCwdPolicy)
172
+ ) {
173
+ return undefined;
174
+ }
175
+ cwdPolicy.consultation = value.cwdPolicy.consultation as ConsultationCwdPolicy;
176
+ }
177
+ if (hasOwn(value.cwdPolicy, "delegation")) {
178
+ if (
179
+ typeof value.cwdPolicy.delegation !== "string" ||
180
+ !DELEGATION_CWD_POLICIES.includes(value.cwdPolicy.delegation as DelegationCwdPolicy)
181
+ ) {
182
+ return undefined;
183
+ }
184
+ cwdPolicy.delegation = value.cwdPolicy.delegation as DelegationCwdPolicy;
185
+ }
186
+ settings.cwdPolicy = cwdPolicy;
187
+ }
161
188
  return settings;
162
189
  }
163
190
 
@@ -165,6 +192,8 @@ const SETTINGS_FILE = "pi-subagents.json";
165
192
  const LEGACY_SETTINGS_FILE = "pi-subagents-config.json";
166
193
  const DEFAULT_COMPLETION_DELIVERY: CompletionDelivery = "next-turn";
167
194
  export const DEFAULT_CONSULT_RESOURCE_POLICY: ConsultResourcePolicy = "project-context";
195
+ export const DEFAULT_CONSULTATION_CWD_POLICY: ConsultationCwdPolicy = "anywhere";
196
+ export const DEFAULT_DELEGATION_CWD_POLICY: DelegationCwdPolicy = "trusted-targets";
168
197
  const SETTINGS_LOCK_FS_ADAPTER = {
169
198
  mkdir: fs.mkdir,
170
199
  mkdirSync: fs.mkdirSync,
@@ -261,6 +290,18 @@ export interface ConsultResourceSettingsSnapshot {
261
290
  error?: string;
262
291
  }
263
292
 
293
+ export interface CwdPolicyFieldSnapshot<T> {
294
+ value: T;
295
+ source: "default" | "user settings";
296
+ }
297
+
298
+ export interface CwdPolicySettingsSnapshot {
299
+ path: string;
300
+ consultation: CwdPolicyFieldSnapshot<ConsultationCwdPolicy>;
301
+ delegation: CwdPolicyFieldSnapshot<DelegationCwdPolicy>;
302
+ error?: string;
303
+ }
304
+
264
305
  export interface SubagentSettingsSnapshot {
265
306
  path: string;
266
307
  settings?: SubagentSettings;
@@ -302,16 +343,28 @@ function inspectSubagentSettingsPath(configPath: string): {
302
343
  settings?: SubagentSettings;
303
344
  error?: string;
304
345
  } {
346
+ const fileName = path.basename(configPath);
347
+ let contents: string;
305
348
  try {
306
- const raw: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
307
- const settings = normalizeSubagentSettings(raw);
308
- if (!isPlainObject(raw) || !settings) {
309
- throw new Error(`${path.basename(configPath)} is not a valid settings object`);
310
- }
311
- return { path: configPath, raw, settings };
349
+ contents = fs.readFileSync(configPath, "utf8");
312
350
  } catch (error) {
313
- return { path: configPath, error: formatError(error) };
351
+ const code = (error as NodeJS.ErrnoException).code;
352
+ return {
353
+ path: configPath,
354
+ error: `${fileName} could not be read${code ? ` (${safeErrorCode(code)})` : ""}`,
355
+ };
314
356
  }
357
+ let raw: unknown;
358
+ try {
359
+ raw = JSON.parse(contents);
360
+ } catch {
361
+ return { path: configPath, error: `${fileName} contains malformed JSON` };
362
+ }
363
+ const settings = normalizeSubagentSettings(raw);
364
+ if (!isPlainObject(raw) || !settings) {
365
+ return { path: configPath, error: `${fileName} is not a valid settings object` };
366
+ }
367
+ return { path: configPath, raw, settings };
315
368
  }
316
369
 
317
370
  export function inspectSubagentSettings(): SubagentSettingsSnapshot {
@@ -343,6 +396,30 @@ export function inspectConsultResourceSettings(): ConsultResourceSettingsSnapsho
343
396
  };
344
397
  }
345
398
 
399
+ export function inspectCwdPolicySettings(): CwdPolicySettingsSnapshot {
400
+ const inspected = inspectSubagentSettingsDocument();
401
+ if (!inspected.raw || !inspected.settings) {
402
+ return {
403
+ path: inspected.path,
404
+ consultation: { value: DEFAULT_CONSULTATION_CWD_POLICY, source: "default" },
405
+ delegation: { value: DEFAULT_DELEGATION_CWD_POLICY, source: "default" },
406
+ ...(inspected.error ? { error: inspected.error } : {}),
407
+ };
408
+ }
409
+ const rawPolicy = isPlainObject(inspected.raw.cwdPolicy) ? inspected.raw.cwdPolicy : undefined;
410
+ return {
411
+ path: inspected.path,
412
+ consultation: {
413
+ value: inspected.settings.cwdPolicy?.consultation ?? DEFAULT_CONSULTATION_CWD_POLICY,
414
+ source: rawPolicy && hasOwn(rawPolicy, "consultation") ? "user settings" : "default",
415
+ },
416
+ delegation: {
417
+ value: inspected.settings.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY,
418
+ source: rawPolicy && hasOwn(rawPolicy, "delegation") ? "user settings" : "default",
419
+ },
420
+ };
421
+ }
422
+
346
423
  export function inspectDelegationWorkflowSettings(): DelegationWorkflowSettingsSnapshot {
347
424
  const inspected = inspectSubagentSettingsDocument();
348
425
  if (!inspected.raw || !inspected.settings) {
@@ -458,6 +535,29 @@ export function updateConsultResourceSetting(value: ConsultResourcePolicy): void
458
535
  });
459
536
  }
460
537
 
538
+ export function updateCwdPolicySetting(field: "consultation", value: ConsultationCwdPolicy): void;
539
+ export function updateCwdPolicySetting(field: "delegation", value: DelegationCwdPolicy): void;
540
+ export function updateCwdPolicySetting(
541
+ field: "consultation" | "delegation",
542
+ value: ConsultationCwdPolicy | DelegationCwdPolicy,
543
+ ): void {
544
+ withSettingsMutationLock(() => {
545
+ const update = readSettingsObjectForUpdate();
546
+ const raw = update.document;
547
+ const cwdPolicy = raw.cwdPolicy;
548
+ if (cwdPolicy !== undefined && !isPlainObject(cwdPolicy)) {
549
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} cwdPolicy settings`);
550
+ }
551
+ writeSettingsObjectUnlocked(
552
+ {
553
+ ...raw,
554
+ cwdPolicy: { ...(cwdPolicy ?? {}), [field]: value },
555
+ },
556
+ update.replaceCanonical,
557
+ );
558
+ });
559
+ }
560
+
461
561
  export function updateAgentToolsSetting(name: string, tools: string[] | undefined): void {
462
562
  withSettingsMutationLock(() => {
463
563
  const update = readSettingsObjectForUpdate();
@@ -504,8 +604,8 @@ function readSettingsObjectForUpdate(): SettingsObjectForUpdate {
504
604
  let parsed: unknown;
505
605
  try {
506
606
  parsed = JSON.parse(fs.readFileSync(activePath, "utf8"));
507
- } catch (error) {
508
- throw new Error(`Cannot update malformed ${activeFile}: ${formatError(error)}`);
607
+ } catch {
608
+ throw new Error(`Cannot update malformed ${activeFile}`);
509
609
  }
510
610
  if (!isPlainObject(parsed) || !normalizeSubagentSettings(parsed)) {
511
611
  throw new Error(`Cannot update invalid ${activeFile}`);
@@ -585,8 +685,8 @@ function readSettingsSnapshot(configPath: string): {
585
685
  }
586
686
  }
587
687
 
588
- function formatError(error: unknown) {
589
- return error instanceof Error ? error.message : String(error);
688
+ function safeErrorCode(value: string): string {
689
+ return value.replace(/[^A-Z0-9_-]/giu, "?").slice(0, 64);
590
690
  }
591
691
 
592
692
  export function uniqueToolNames(tools: string[]): string[] {
@@ -0,0 +1,35 @@
1
+ import type { CompletionDelivery } from "./agents.js";
2
+
3
+ export function createSpawnPromptGuidelines(
4
+ completionDelivery: CompletionDelivery,
5
+ blockingEnabled = true,
6
+ ): string[] {
7
+ const deliveryGuidance =
8
+ completionDelivery === "auto-resume"
9
+ ? blockingEnabled
10
+ ? "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result; do not choose blocking parallel fan-out merely to keep delegation in the same turn."
11
+ : "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result."
12
+ : blockingEnabled
13
+ ? "With subagent_spawn completion delivery set to next-turn (the default), prefer one subagent_spawn for broad asynchronous research or review only when the current response does not depend on its result; use the blocking subagent when the final answer depends on the detached result."
14
+ : "With subagent_spawn completion delivery set to next-turn (the default), use subagent_spawn only when the current response does not depend on its result; complete final-answer-dependent work directly because an idle root is not awakened.";
15
+ const noLocalWorkGuidance =
16
+ completionDelivery === "auto-resume"
17
+ ? "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response; auto-resume will request a synthesis turn after completion."
18
+ : "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response only when the current response does not depend on its result; next-turn delivery will not wake an idle root.";
19
+ return [
20
+ "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
21
+ "Set subagent_spawn thinkingLevel to the lowest sufficient thinking level for the delegated task: use off or minimal for extraction, formatting, or mechanical work; low for straightforward bounded work; medium for ordinary multi-step research or implementation; high for complex debugging, design, review, or cross-file analysis; xhigh for highly ambiguous, cross-system, or high-risk analysis; and max only for the hardest tasks when quality clearly outweighs latency and cost. Omit subagent_spawn thinkingLevel only to preserve the agent or child default.",
22
+ deliveryGuidance,
23
+ "Use a single subagent_spawn only for a concrete bounded subtask that can run independently and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
24
+ ...(blockingEnabled
25
+ ? [
26
+ "Use the blocking subagent instead of subagent_spawn when synchronous output is required before the main agent can continue and waiting is intentional; queued steering cannot be processed until that blocking call returns.",
27
+ "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
28
+ ]
29
+ : []),
30
+ "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
31
+ noLocalWorkGuidance,
32
+ 'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
33
+ 'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_manage action "list" or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
34
+ ];
35
+ }