pi-subagents 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/agents.md +1 -1
  3. package/docs/configuration.md +14 -6
  4. package/docs/extension-api.md +1 -1
  5. package/docs/missions.md +5 -3
  6. package/docs/models.md +3 -1
  7. package/docs/observability.md +3 -3
  8. package/docs/tool-reference.md +2 -2
  9. package/docs/watchdog.md +1 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/references/execution-controls.md +4 -4
  12. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  13. package/src/extension/config.ts +3 -0
  14. package/src/extension/fanout-child.ts +5 -4
  15. package/src/extension/index.ts +30 -3
  16. package/src/extension/rpc.ts +3 -6
  17. package/src/extension/schemas.ts +25 -5
  18. package/src/extension/tool-description.ts +26 -8
  19. package/src/inspectors/herdr/project-panes.ts +2 -1
  20. package/src/missions/store.ts +2 -1
  21. package/src/missions/workflow-state.ts +19 -13
  22. package/src/runs/background/async-execution.ts +10 -5
  23. package/src/runs/background/async-job-tracker.ts +15 -0
  24. package/src/runs/background/async-resume.ts +19 -3
  25. package/src/runs/background/async-status.ts +6 -1
  26. package/src/runs/background/control-channel.ts +36 -0
  27. package/src/runs/background/result-watcher.ts +16 -2
  28. package/src/runs/background/scheduled-runs.ts +2 -1
  29. package/src/runs/background/stale-run-reconciler.ts +2 -21
  30. package/src/runs/background/subagent-runner.ts +47 -6
  31. package/src/runs/foreground/async-steering-action.ts +1 -1
  32. package/src/runs/foreground/chain-execution.ts +3 -0
  33. package/src/runs/foreground/execution.ts +3 -0
  34. package/src/runs/foreground/subagent-executor.ts +95 -12
  35. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  36. package/src/runs/shared/dynamic-fanout.ts +1 -1
  37. package/src/runs/shared/model-fallback.ts +8 -4
  38. package/src/runs/shared/model-scope.ts +12 -2
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/worktree.ts +3 -2
  41. package/src/shared/artifacts.ts +14 -14
  42. package/src/shared/display-text.ts +100 -0
  43. package/src/shared/formatters.ts +4 -6
  44. package/src/shared/settings.ts +15 -2
  45. package/src/shared/types.ts +11 -1
  46. package/src/shared/utils.ts +43 -33
  47. package/src/slash/slash-commands.ts +3 -1
  48. package/src/tui/fleet-status.ts +14 -10
  49. package/src/tui/render.ts +30 -26
  50. package/src/watchdog/change-signature.ts +4 -3
@@ -0,0 +1,100 @@
1
+ function codePointWidth(codePoint: number): 1 | 2 {
2
+ return codePoint > 0xffff ? 2 : 1;
3
+ }
4
+
5
+ function isWhitespaceOrControl(codePoint: number): boolean {
6
+ if (codePoint <= 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) return true;
7
+ if (codePoint >= 0xd800 && codePoint <= 0xdfff) return true;
8
+ return String.fromCodePoint(codePoint).trim().length === 0;
9
+ }
10
+
11
+ function consumeControlString(value: string, index: number, osc: boolean): number {
12
+ while (index < value.length) {
13
+ const codePoint = value.codePointAt(index)!;
14
+ const width = codePointWidth(codePoint);
15
+ if (osc && codePoint === 0x07) return index + width;
16
+ if (codePoint === 0x9c) return index + width;
17
+ if (codePoint === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
18
+ index += width;
19
+ }
20
+ return value.length;
21
+ }
22
+
23
+ function consumeCsi(value: string, index: number): number {
24
+ while (index < value.length) {
25
+ const codePoint = value.codePointAt(index)!;
26
+ const width = codePointWidth(codePoint);
27
+ if (codePoint >= 0x40 && codePoint <= 0x7e) return index + width;
28
+ index += width;
29
+ }
30
+ return value.length;
31
+ }
32
+
33
+ export function sanitizeDisplayText(value: string): string {
34
+ const output: string[] = [];
35
+ let pendingSpace = false;
36
+
37
+ const appendSpace = (): void => {
38
+ if (output.length > 0) pendingSpace = true;
39
+ };
40
+ const appendText = (text: string): void => {
41
+ if (pendingSpace) output.push(" ");
42
+ output.push(text);
43
+ pendingSpace = false;
44
+ };
45
+
46
+ for (let index = 0; index < value.length;) {
47
+ const codePoint = value.codePointAt(index)!;
48
+ const width = codePointWidth(codePoint);
49
+
50
+ if (codePoint === 0x1b) {
51
+ const next = value.charCodeAt(index + 1);
52
+ appendSpace();
53
+ if (next === 0x5b) {
54
+ index = consumeCsi(value, index + 2);
55
+ continue;
56
+ }
57
+ if (next === 0x5d || next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
58
+ index = consumeControlString(value, index + 2, next === 0x5d);
59
+ continue;
60
+ }
61
+ index += next ? 2 : 1;
62
+ continue;
63
+ }
64
+
65
+ if (codePoint === 0x9b) {
66
+ appendSpace();
67
+ index = consumeCsi(value, index + width);
68
+ continue;
69
+ }
70
+ if (codePoint === 0x90 || codePoint === 0x98 || codePoint === 0x9d || codePoint === 0x9e || codePoint === 0x9f) {
71
+ appendSpace();
72
+ index = consumeControlString(value, index + width, codePoint === 0x9d);
73
+ continue;
74
+ }
75
+
76
+ if (isWhitespaceOrControl(codePoint)) appendSpace();
77
+ else appendText(String.fromCodePoint(codePoint));
78
+ index += width;
79
+ }
80
+
81
+ return output.join("");
82
+ }
83
+
84
+ export function truncateDisplayText(value: string, maxLength: number): string {
85
+ if (maxLength <= 0) return "";
86
+ if (value.length <= maxLength) return value;
87
+ let output = "";
88
+ for (const char of value) {
89
+ if (output.length + char.length > maxLength) break;
90
+ output += char;
91
+ }
92
+ return output;
93
+ }
94
+
95
+ export function previewDisplayText(value: string, maxLength: number): string {
96
+ const normalized = sanitizeDisplayText(value);
97
+ if (normalized.length <= maxLength) return normalized;
98
+ if (maxLength <= 3) return truncateDisplayText(normalized, maxLength);
99
+ return `${truncateDisplayText(normalized, maxLength - 3)}...`;
100
+ }
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import type { Usage, SingleResult } from "./types.ts";
8
8
  import type { ChainStep } from "./settings.ts";
9
9
  import { isDynamicParallelStep, isParallelStep } from "./settings.ts";
10
+ import { previewDisplayText, sanitizeDisplayText } from "./display-text.ts";
10
11
  import { splitKnownThinkingSuffix, THINKING_LEVELS } from "./model-info.ts";
11
12
 
12
13
  /**
@@ -100,8 +101,7 @@ export function formatToolCall(name: string, args: Record<string, unknown>, expa
100
101
  switch (name) {
101
102
  case "bash": {
102
103
  const command = typeof args.command === "string" ? args.command : "";
103
- const maxLength = expanded ? 240 : 60;
104
- return `$ ${command.slice(0, maxLength)}${command.length > maxLength ? "..." : ""}`;
104
+ return `$ ${previewDisplayText(command, expanded ? 240 : 60)}`;
105
105
  }
106
106
  case "read":
107
107
  case "write":
@@ -111,12 +111,10 @@ export function formatToolCall(name: string, args: Record<string, unknown>, expa
111
111
  : typeof args.file_path === "string"
112
112
  ? args.file_path
113
113
  : "";
114
- return `${name} ${shortenPath(target)}`;
114
+ return `${name} ${sanitizeDisplayText(shortenPath(target))}`;
115
115
  }
116
116
  default: {
117
- const s = JSON.stringify(args);
118
- const maxLength = expanded ? 160 : 40;
119
- return `${name} ${s.slice(0, maxLength)}${s.length > maxLength ? "..." : ""}`;
117
+ return `${name} ${previewDisplayText(JSON.stringify(args), expanded ? 160 : 40)}`;
120
118
  }
121
119
  }
122
120
  }
@@ -353,6 +353,18 @@ export function resolveChainPath(filePath: string, chainDir: string): string {
353
353
  return path.isAbsolute(expanded) ? expanded : path.join(chainDir, expanded);
354
354
  }
355
355
 
356
+ export function resolveExistingReadInstructionPaths(reads: readonly string[], instructionCwd: string, existenceCwd = instructionCwd): string[] {
357
+ return reads.flatMap((filePath) => {
358
+ const instructionPath = resolveChainPath(filePath, instructionCwd);
359
+ const existencePath = resolveChainPath(filePath, existenceCwd);
360
+ return fs.existsSync(existencePath) ? [instructionPath] : [];
361
+ });
362
+ }
363
+
364
+ export function resolveExistingReadPaths(reads: readonly string[], cwd: string): string[] {
365
+ return resolveExistingReadInstructionPaths(reads, cwd);
366
+ }
367
+
356
368
  /**
357
369
  * Build chain instructions from resolved behavior.
358
370
  * These are appended to the task to tell the agent what to read/write.
@@ -367,14 +379,15 @@ export function buildChainInstructions(
367
379
  chainDir: string,
368
380
  isFirstProgressAgent: boolean,
369
381
  previousSummary?: string,
382
+ readExistenceDir = chainDir,
370
383
  ): { prefix: string; suffix: string } {
371
384
  const prefixParts: string[] = [];
372
385
  const suffixParts: string[] = [];
373
386
 
374
387
  // READS - prepend to override any hardcoded filenames in task text
375
388
  if (behavior.reads && behavior.reads.length > 0) {
376
- const files = behavior.reads.map((f) => resolveChainPath(f, chainDir));
377
- prefixParts.push(`[Read from: ${files.join(", ")}]`);
389
+ const files = resolveExistingReadInstructionPaths(behavior.reads, chainDir, readExistenceDir);
390
+ if (files.length > 0) prefixParts.push(`[Read from: ${files.join(", ")}]`);
378
391
  }
379
392
 
380
393
  // OUTPUT - prepend so agent knows where to write
@@ -1545,6 +1545,8 @@ export interface ForegroundRunControl {
1545
1545
  parentWorkflowRunId?: string;
1546
1546
  /** Stable workflow lane key for this live foreground child. */
1547
1547
  workflowKey?: string;
1548
+ /** Private control root used to steer a live workflow-owned child. */
1549
+ workflowSteeringDir?: string;
1548
1550
  /** Originating parent session; required for public fleet projection. */
1549
1551
  sessionId?: string;
1550
1552
  mode: SubagentRunMode;
@@ -1614,6 +1616,8 @@ export interface SubagentState {
1614
1616
  fleetJobs?: Map<string, AsyncJobState>;
1615
1617
  /** Suppress dynamic status widgets while the fleet overlay owns the viewport. */
1616
1618
  fleetInspectorOpen?: boolean;
1619
+ /** Temporarily suppress dynamic widgets while Pi compacts the session. */
1620
+ widgetsSuspended?: boolean;
1617
1621
  foregroundRuns?: Map<string, ForegroundResumeRun>;
1618
1622
  foregroundControls: Map<string, ForegroundRunControl>;
1619
1623
  lastForegroundControlId: string | null;
@@ -1680,6 +1684,10 @@ export interface RunSyncOptions {
1680
1684
  permissions?: import("../runs/shared/permissions.ts").PermissionConfig;
1681
1685
  /** Session id of the direct parent session for permission-system ask forwarding. */
1682
1686
  parentSessionId?: string;
1687
+ /** Private prompt-runtime steering transport for workflow-owned foreground children. */
1688
+ steerInboxDir?: string;
1689
+ steerCapabilityPath?: string;
1690
+ steerAckDir?: string;
1683
1691
  /** Resolved launch context for this child. */
1684
1692
  context?: "fresh" | "fork";
1685
1693
  cwd?: string;
@@ -1819,6 +1827,8 @@ export interface ExtensionConfig {
1819
1827
  asyncWidget?: boolean;
1820
1828
  /** Tool description variant registered for the parent-facing subagent tool. Defaults to full. */
1821
1829
  toolDescriptionMode?: ToolDescriptionMode;
1830
+ /** Include legacy append-step and checkpoint controls in the registered tool schema. Defaults to false. */
1831
+ legacyChainControls?: boolean;
1822
1832
  /** Inline chat rendering for the subagent tool. Defaults to rich. */
1823
1833
  inlineToolDisplay?: InlineToolDisplay;
1824
1834
  forceTopLevelAsync?: boolean;
@@ -1842,7 +1852,7 @@ export interface ExtensionConfig {
1842
1852
  worktreeSetupHook?: string;
1843
1853
  worktreeSetupHookTimeoutMs?: number;
1844
1854
  worktreeBaseDir?: string;
1845
- /** Where to store subagent artifact files. Defaults to "project" (cwd/.pi-subagents). Set to "session" for pi session dir, or "temp" for OS temp. */
1855
+ /** Where to store subagent artifact files. Defaults to "project" (cwd/.pi/subagents). Set to "session" for pi session dir, or "temp" for OS temp. */
1846
1856
  artifactDir?: ArtifactDirPreference;
1847
1857
  intercomBridge?: IntercomBridgeConfig;
1848
1858
  proactiveSkillSubagents?: ProactiveSkillSubagentsConfig | false;
@@ -6,6 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import type { Message } from "@earendil-works/pi-ai";
9
+ import { previewDisplayText, sanitizeDisplayText, truncateDisplayText } from "./display-text.ts";
9
10
  import { formatToolCall } from "./formatters.ts";
10
11
  import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, NestedRunSummary, SingleResult, ToolCallSummary, Usage } from "./types.ts";
11
12
 
@@ -101,6 +102,23 @@ export function getAgentDir(): string {
101
102
 
102
103
  const statusCache = new Map<string, { mtime: number; ctime: number; size: number; ino: number; status: AsyncStatus }>();
103
104
 
105
+ export function pruneStatusCacheForAsyncRoot(asyncDirRoot: string, runIds: Iterable<string>): number {
106
+ const root = path.resolve(asyncDirRoot);
107
+ const currentStatusPaths = new Set(
108
+ Array.from(runIds, (runId) => path.resolve(root, runId, "status.json")),
109
+ );
110
+ let removed = 0;
111
+ for (const statusPath of statusCache.keys()) {
112
+ const resolved = path.resolve(statusPath);
113
+ const relative = path.relative(root, resolved);
114
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative) && !currentStatusPaths.has(resolved)) {
115
+ statusCache.delete(statusPath);
116
+ removed++;
117
+ }
118
+ }
119
+ return removed;
120
+ }
121
+
104
122
  function getErrorMessage(error: unknown): string {
105
123
  return error instanceof Error ? error.message : String(error);
106
124
  }
@@ -127,7 +145,10 @@ export function readStatus(asyncDir: string): AsyncStatus | null {
127
145
  try {
128
146
  stat = fs.statSync(statusPath);
129
147
  } catch (error) {
130
- if (isNotFoundError(error)) return null;
148
+ if (isNotFoundError(error)) {
149
+ statusCache.delete(statusPath);
150
+ return null;
151
+ }
131
152
  throw new Error(`Failed to inspect async status file '${statusPath}': ${getErrorMessage(error)}`, {
132
153
  cause: error instanceof Error ? error : undefined,
133
154
  });
@@ -148,7 +169,10 @@ export function readStatus(asyncDir: string): AsyncStatus | null {
148
169
  try {
149
170
  content = fs.readFileSync(statusPath, "utf-8");
150
171
  } catch (error) {
151
- if (isNotFoundError(error)) return null;
172
+ if (isNotFoundError(error)) {
173
+ statusCache.delete(statusPath);
174
+ return null;
175
+ }
152
176
  throw new Error(`Failed to read async status file '${statusPath}': ${getErrorMessage(error)}`, {
153
177
  cause: error instanceof Error ? error : undefined,
154
178
  });
@@ -170,10 +194,6 @@ export function readStatus(asyncDir: string): AsyncStatus | null {
170
194
  ino: stat.ino,
171
195
  status,
172
196
  });
173
- if (statusCache.size > 50) {
174
- const firstKey = statusCache.keys().next().value;
175
- if (firstKey) statusCache.delete(firstKey);
176
- }
177
197
  return status;
178
198
  }
179
199
 
@@ -522,11 +542,8 @@ export function detectSubagentError(messages: Message[]): ErrorInfo {
522
542
  * Extract a preview of tool arguments for display
523
543
  */
524
544
  export function extractToolArgsPreview(args: Record<string, unknown>): string {
525
- const truncatePreview = (value: string, maxLength: number): string =>
526
- value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
527
-
528
545
  const stringifyPreviewValue = (value: unknown): string | undefined => {
529
- if (typeof value === "string" && value.trim().length > 0) return value;
546
+ if (typeof value === "string" && value.trim().length > 0) return sanitizeDisplayText(value);
530
547
  if (typeof value === "number" || typeof value === "boolean") return String(value);
531
548
  return undefined;
532
549
  };
@@ -539,39 +556,32 @@ export function extractToolArgsPreview(args: Record<string, unknown>): string {
539
556
  return `${first}${suffix}`;
540
557
  };
541
558
 
542
- // Handle MCP tool calls - show server/tool info
543
- if (args.tool && typeof args.tool === "string") {
544
- const server = args.server && typeof args.server === "string" ? `${args.server}/` : "";
545
- const toolArgs = args.args && typeof args.args === "string" ? ` ${args.args.slice(0, 40)}` : "";
546
- return `${server}${args.tool}${toolArgs}`;
559
+ if (typeof args.tool === "string") {
560
+ const server = typeof args.server === "string" ? `${sanitizeDisplayText(args.server)}/` : "";
561
+ const toolArgs = typeof args.args === "string" ? ` ${truncateDisplayText(sanitizeDisplayText(args.args), 40)}` : "";
562
+ return sanitizeDisplayText(`${server}${args.tool}${toolArgs}`);
547
563
  }
548
564
 
549
565
  const queriesPreview = previewArray(args.queries);
550
- if (queriesPreview) return truncatePreview(queriesPreview, 60);
551
- if (typeof args.query === "string" && args.query.trim().length > 0) return truncatePreview(args.query, 60);
552
- if (typeof args.workflow === "string" && args.workflow.trim().length > 0) return `workflow=${truncatePreview(args.workflow, 48)}`;
566
+ if (queriesPreview) return previewDisplayText(queriesPreview, 60);
567
+ if (typeof args.query === "string" && args.query.trim().length > 0) return previewDisplayText(args.query, 60);
568
+ if (typeof args.workflow === "string" && args.workflow.trim().length > 0) return `workflow=${previewDisplayText(args.workflow, 48)}`;
553
569
 
554
- if (typeof args.url === "string" && args.url.trim().length > 0) return truncatePreview(args.url, 60);
570
+ if (typeof args.url === "string" && args.url.trim().length > 0) return previewDisplayText(args.url, 60);
555
571
  const urlsPreview = previewArray(args.urls);
556
- if (urlsPreview) return truncatePreview(urlsPreview, 60);
557
- if (typeof args.prompt === "string" && args.prompt.trim().length > 0) return truncatePreview(args.prompt, 60);
558
-
572
+ if (urlsPreview) return previewDisplayText(urlsPreview, 60);
573
+ if (typeof args.prompt === "string" && args.prompt.trim().length > 0) return previewDisplayText(args.prompt, 60);
574
+
559
575
  const previewKeys = ["command", "path", "file_path", "pattern", "query", "url", "task", "describe", "search"];
560
576
  for (const key of previewKeys) {
561
- if (args[key] && typeof args[key] === "string") {
562
- const value = args[key] as string;
563
- return truncatePreview(value, 60);
564
- }
577
+ if (typeof args[key] === "string") return previewDisplayText(args[key], 60);
565
578
  }
566
-
567
- // Fallback: show first string value found
579
+
568
580
  for (const [key, value] of Object.entries(args)) {
581
+ const displayKey = sanitizeDisplayText(key);
569
582
  const arrayPreview = previewArray(value);
570
- if (arrayPreview) return `${key}=${truncatePreview(arrayPreview, 50)}`;
571
- if (typeof value === "string" && value.length > 0) {
572
- const preview = truncatePreview(value, 50);
573
- return `${key}=${preview}`;
574
- }
583
+ if (arrayPreview) return `${displayKey}=${previewDisplayText(arrayPreview, 50)}`;
584
+ if (typeof value === "string" && value.length > 0) return `${displayKey}=${previewDisplayText(value, 50)}`;
575
585
  }
576
586
  return "";
577
587
  }
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { keyText, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { Key, matchesKey, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
6
6
  import { BUILTIN_AGENT_NAMES, discoverAgents } from "../agents/agents.ts";
7
+ import { resolveExistingReadPaths } from "../shared/settings.ts";
7
8
  import {
8
9
  DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS,
9
10
  applySubagentProfile,
@@ -675,7 +676,8 @@ export function registerSlashCommands(
675
676
 
676
677
  let finalTask = task;
677
678
  if (inline.reads && Array.isArray(inline.reads) && inline.reads.length > 0) {
678
- finalTask = `[Read from: ${inline.reads.join(", ")}]\n\n${finalTask}`;
679
+ const existingReads = inline.reads.filter((read) => resolveExistingReadPaths([read], state.baseCwd).length > 0);
680
+ if (existingReads.length > 0) finalTask = `[Read from: ${existingReads.join(", ")}]\n\n${finalTask}`;
679
681
  }
680
682
  const child: Record<string, unknown> = { agent: agentName, task: finalTask, agentScope: "both" };
681
683
  if (inline.output !== undefined) child.output = inline.output;
@@ -307,26 +307,22 @@ export class SubagentFleetStatus {
307
307
  refresh(): void {
308
308
  const ctx = this.getActiveUiContext();
309
309
  if (!ctx) return;
310
+ if (this.state.widgetsSuspended) {
311
+ this.clearWidget();
312
+ return;
313
+ }
310
314
  this.entries = collectFleetStatusEntries(this.state);
311
315
  this.clampSelection();
312
316
  if (this.inspectorOpen || this.state.fleetInspectorOpen) {
313
317
  this.lastRenderKey = "";
314
- if (this.widgetRegistered) {
315
- ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
316
- this.widgetRegistered = false;
317
- this.tui = undefined;
318
- }
318
+ this.clearWidget();
319
319
  return;
320
320
  }
321
321
  if (this.entries.length === 0) {
322
322
  this.active = false;
323
323
  this.selectedKey = "main";
324
324
  this.lastRenderKey = "";
325
- if (this.widgetRegistered) {
326
- ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
327
- this.widgetRegistered = false;
328
- this.tui = undefined;
329
- }
325
+ this.clearWidget();
330
326
  return;
331
327
  }
332
328
 
@@ -356,6 +352,7 @@ export class SubagentFleetStatus {
356
352
  }
357
353
 
358
354
  handleKey(data: string): { consume?: boolean; data?: string } | undefined {
355
+ if (this.state.widgetsSuspended) return undefined;
359
356
  const ctx = this.getActiveUiContext();
360
357
  if (!ctx || this.entries.length === 0 || isKeyRelease(data)) return undefined;
361
358
  if (this.inspectorOpen) return undefined;
@@ -536,6 +533,13 @@ export class SubagentFleetStatus {
536
533
  }
537
534
  }
538
535
 
536
+ private clearWidget(): void {
537
+ if (!this.widgetRegistered) return;
538
+ this.ui?.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
539
+ this.widgetRegistered = false;
540
+ this.tui = undefined;
541
+ }
542
+
539
543
  private clearUiRegistration(): void {
540
544
  if (this.timer) clearInterval(this.timer);
541
545
  this.timer = undefined;
package/src/tui/render.ts CHANGED
@@ -16,8 +16,10 @@ import {
16
16
  type NestedStepSummary,
17
17
  type WorkflowNodeStatus,
18
18
  MAX_WIDGET_JOBS,
19
+ POLL_INTERVAL_MS,
19
20
  WIDGET_KEY,
20
21
  } from "../shared/types.ts";
22
+ import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text.ts";
21
23
  import { formatTokens, formatUsage, formatDuration, formatModelThinking, formatToolCall, shortenPath } from "../shared/formatters.ts";
22
24
  import { getDisplayItems, getSingleResultOutput } from "../shared/utils.ts";
23
25
  import { flatToLogicalStepIndex } from "../runs/background/parallel-groups.ts";
@@ -320,6 +322,12 @@ function snapshotNowForProgress(progress: Pick<AgentProgress, "currentToolStarte
320
322
  return progress.lastActivityAt;
321
323
  }
322
324
 
325
+ function renderToolArgsPreview(value: string, maxLength: number, expanded: boolean): string {
326
+ const normalized = sanitizeDisplayText(value);
327
+ if (expanded || normalized.length <= maxLength) return normalized;
328
+ return `${truncateDisplayText(normalized, maxLength)}...`;
329
+ }
330
+
323
331
  function formatCurrentToolLine(
324
332
  progress: Pick<AgentProgress, "currentTool" | "currentToolArgs" | "currentToolStartedAt">,
325
333
  availableWidth: number,
@@ -329,9 +337,7 @@ function formatCurrentToolLine(
329
337
  if (!progress.currentTool) return undefined;
330
338
  const maxToolArgsLen = Math.max(50, availableWidth - 20);
331
339
  const toolArgsPreview = progress.currentToolArgs
332
- ? (expanded || progress.currentToolArgs.length <= maxToolArgsLen
333
- ? progress.currentToolArgs
334
- : `${progress.currentToolArgs.slice(0, maxToolArgsLen)}...`)
340
+ ? renderToolArgsPreview(progress.currentToolArgs, maxToolArgsLen, expanded)
335
341
  : "";
336
342
  const durationSuffix = progress.currentToolStartedAt !== undefined && snapshotNow !== undefined
337
343
  ? ` | ${formatDuration(Math.max(0, snapshotNow - progress.currentToolStartedAt))}`
@@ -622,7 +628,7 @@ function widgetStepActivity(step: NonNullable<AsyncJobState["steps"]>[number], s
622
628
  }
623
629
 
624
630
 
625
- function widgetChainDetails(job: AsyncJobState, theme: Theme, expanded = false, width = getTermWidth()): string[] {
631
+ function widgetChainDetails(job: AsyncJobState, theme: Theme, expanded = false, width = getTermWidth(), frame?: number): string[] {
626
632
  if (!job.steps?.length) return [];
627
633
  const total = job.chainStepCount ?? job.steps.length;
628
634
  const lines: string[] = [];
@@ -630,7 +636,7 @@ function widgetChainDetails(job: AsyncJobState, theme: Theme, expanded = false,
630
636
  const steps = job.steps.slice(span.start, span.start + span.count);
631
637
  if (span.isParallel) {
632
638
  const status = aggregateStepStatus(steps);
633
- lines.push(` ${widgetStepGlyph(status, theme, widgetStepsRunningSeed(steps))} Step ${span.stepIndex + 1}/${total}: ${themeBold(theme, "parallel group")} ${theme.fg("dim", "·")} ${theme.fg("dim", formatParallelOutcome(steps, span.count))}`);
639
+ lines.push(` ${widgetStepGlyph(status, theme, widgetStepsRunningSeed(steps), frame)} Step ${span.stepIndex + 1}/${total}: ${themeBold(theme, "parallel group")} ${theme.fg("dim", "·")} ${theme.fg("dim", formatParallelOutcome(steps, span.count))}`);
634
640
  continue;
635
641
  }
636
642
  const step = steps[0];
@@ -638,15 +644,15 @@ function widgetChainDetails(job: AsyncJobState, theme: Theme, expanded = false,
638
644
  lines.push(` ${theme.fg("dim", `◦ Step ${span.stepIndex + 1}/${total}: pending`)}`);
639
645
  continue;
640
646
  }
641
- lines.push(...foregroundStyleWidgetStepLines(job, theme, step, "Step", span.stepIndex + 1, total, expanded, width));
647
+ lines.push(...foregroundStyleWidgetStepLines(job, theme, step, "Step", span.stepIndex + 1, total, expanded, width, frame));
642
648
  }
643
649
  return lines;
644
650
  }
645
651
 
646
- function widgetParallelAgentDetails(job: AsyncJobState, theme: Theme, expanded = false, width = getTermWidth()): string[] {
652
+ function widgetParallelAgentDetails(job: AsyncJobState, theme: Theme, expanded = false, width = getTermWidth(), frame?: number): string[] {
647
653
  if (!job.steps?.length) return [];
648
654
  if (job.mode !== "parallel" && job.mode !== "chain") return [];
649
- if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width);
655
+ if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width, frame);
650
656
  const total = job.stepsTotal ?? job.steps.length;
651
657
  const lines: string[] = [];
652
658
  for (const [index, step] of job.steps.entries()) {
@@ -654,7 +660,7 @@ function widgetParallelAgentDetails(job: AsyncJobState, theme: Theme, expanded =
654
660
  const activity = widgetStepActivity(step, job.updatedAt);
655
661
  const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
656
662
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
657
- lines.push(` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index))} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${modelDisplay}${activity ? ` · ${activity}` : ""}`)}`);
663
+ lines.push(` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index), frame)} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${modelDisplay}${activity ? ` · ${activity}` : ""}`)}`);
658
664
  for (const nestedLine of formatNestedWidgetLines(step.children, theme, width, expanded, job.updatedAt, expanded ? 8 : 6)) lines.push(` ${nestedLine}`);
659
665
  }
660
666
  return lines;
@@ -1147,7 +1153,7 @@ function foregroundStyleWidgetStepLines(
1147
1153
  if (liveStatus && liveStatus !== activity) lines.push(` ${theme.fg("accent", liveStatus)}`);
1148
1154
  for (const tool of step.recentTools?.slice(-3) ?? []) {
1149
1155
  const maxArgsLen = Math.max(40, width - 30);
1150
- const argsPreview = tool.args.length <= maxArgsLen ? tool.args : `${tool.args.slice(0, maxArgsLen)}...`;
1156
+ const argsPreview = renderToolArgsPreview(tool.args, maxArgsLen, expanded);
1151
1157
  lines.push(` ${theme.fg("dim", `${tool.tool}${argsPreview ? `: ${argsPreview}` : ""}`)}`);
1152
1158
  }
1153
1159
  for (const line of compactRecentOutputLines(step.recentOutput)) {
@@ -1163,7 +1169,7 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
1163
1169
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1164
1170
  ...formatNestedWidgetLines(job.nestedChildren, theme, width, expanded, job.updatedAt, expanded ? 12 : 6).map((line) => ` ${line}`),
1165
1171
  ];
1166
- if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width);
1172
+ if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width, frame);
1167
1173
  const total = job.stepsTotal ?? job.steps.length;
1168
1174
  const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
1169
1175
  const lines: string[] = [];
@@ -1411,10 +1417,10 @@ function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expan
1411
1417
  return [...lines.slice(0, visibleLines), truncLine(theme.fg("dim", hint), width)];
1412
1418
  }
1413
1419
 
1414
- function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
1420
+ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], buildLines: () => string[], theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
1415
1421
  if (expanded) {
1416
1422
  resetWidgetLayoutSession();
1417
- return fitWidgetLineBudget(lines, theme, width, true);
1423
+ return fitWidgetLineBudget(buildLines(), theme, width, true);
1418
1424
  }
1419
1425
 
1420
1426
  const hasMatchingSession = widgetSessionMatches(expanded);
@@ -1432,6 +1438,7 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: T
1432
1438
  return rendered.lines;
1433
1439
  }
1434
1440
 
1441
+ const lines = buildLines();
1435
1442
  if (lines.length <= availableRows) {
1436
1443
  widgetLayoutSession = { expanded, rows, columns, tier: "full", visibleJobKeys: [] };
1437
1444
  return fitWidgetLineBudget(lines, theme, width, false);
@@ -1453,12 +1460,13 @@ function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (_tui:
1453
1460
  const container = new Container();
1454
1461
  container.render = (renderWidth: number): string[] => {
1455
1462
  const width = Math.max(0, renderWidth - 2);
1456
- const lines = expanded
1457
- ? buildWidgetLines(jobs, theme, width, true)
1463
+ const frame = Math.floor(Date.now() / POLL_INTERVAL_MS);
1464
+ const buildLines = (): string[] => expanded
1465
+ ? buildWidgetLines(jobs, theme, width, true, frame)
1458
1466
  : jobs.length === 1
1459
- ? compactSingleWidgetLines(jobs[0]!, theme, width)
1460
- : buildWidgetLines(jobs, theme, width, false);
1461
- return fitAdaptiveWidgetLines(jobs, lines, theme, width, expanded).map((line) => paddedWidgetLine(line, renderWidth));
1467
+ ? compactSingleWidgetLines(jobs[0]!, theme, width, frame)
1468
+ : buildWidgetLines(jobs, theme, width, false, frame);
1469
+ return fitAdaptiveWidgetLines(jobs, buildLines, theme, width, expanded, frame).map((line) => paddedWidgetLine(line, renderWidth));
1462
1470
  };
1463
1471
  return container;
1464
1472
  };
@@ -1488,7 +1496,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1488
1496
  items.push([
1489
1497
  `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1490
1498
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1491
- ...widgetParallelAgentDetails(job, theme, expanded, width),
1499
+ ...widgetParallelAgentDetails(job, theme, expanded, width, frame),
1492
1500
  ]);
1493
1501
  slots--;
1494
1502
  }
@@ -1505,7 +1513,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1505
1513
  items.push([
1506
1514
  `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1507
1515
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1508
- ...widgetParallelAgentDetails(job, theme, expanded, width),
1516
+ ...widgetParallelAgentDetails(job, theme, expanded, width, frame),
1509
1517
  ]);
1510
1518
  slots--;
1511
1519
  }
@@ -1870,9 +1878,7 @@ export function renderSubagentResult(
1870
1878
  if (r.progress.recentTools?.length) {
1871
1879
  for (const t of r.progress.recentTools.slice(-3)) {
1872
1880
  const maxArgsLen = Math.max(40, w - 24);
1873
- const argsPreview = expanded || t.args.length <= maxArgsLen
1874
- ? t.args
1875
- : `${t.args.slice(0, maxArgsLen)}...`;
1881
+ const argsPreview = renderToolArgsPreview(t.args, maxArgsLen, expanded);
1876
1882
  c.addChild(new Text(fit(theme.fg("dim", `${t.tool}: ${argsPreview}`)), 0, 0));
1877
1883
  }
1878
1884
  }
@@ -2103,9 +2109,7 @@ export function renderSubagentResult(
2103
2109
  if (rProg.recentTools?.length) {
2104
2110
  for (const t of rProg.recentTools.slice(-3)) {
2105
2111
  const maxArgsLen = Math.max(40, w - 30);
2106
- const argsPreview = expanded || t.args.length <= maxArgsLen
2107
- ? t.args
2108
- : `${t.args.slice(0, maxArgsLen)}...`;
2112
+ const argsPreview = renderToolArgsPreview(t.args, maxArgsLen, expanded);
2109
2113
  c.addChild(new Text(fit(theme.fg("dim", ` ${t.tool}: ${argsPreview}`)), 0, 0));
2110
2114
  }
2111
2115
  }
@@ -2,10 +2,11 @@ import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
+ import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../shared/artifacts.ts";
5
6
 
6
- const IGNORED_CHANGE_PREFIXES = [".pi-subagents/", "tmp/", "node_modules/"];
7
- const IGNORED_CHANGE_PATHS = new Set([".pi-subagents", "tmp", "node_modules"]);
8
- const IGNORED_CHANGE_SEGMENTS = new Set([".git", ".pi-subagents", "node_modules"]);
7
+ const IGNORED_CHANGE_PREFIXES = [`${PROJECT_SUBAGENTS_RELATIVE_DIR}/`, "tmp/", "node_modules/"];
8
+ const IGNORED_CHANGE_PATHS = new Set([PROJECT_SUBAGENTS_RELATIVE_DIR, "tmp", "node_modules"]);
9
+ const IGNORED_CHANGE_SEGMENTS = new Set([".git", "node_modules"]);
9
10
 
10
11
  const DEFAULT_MAX_HASH_FILE_BYTES = 64 * 1024 * 1024;
11
12
  const DEFAULT_MAX_HASH_TOTAL_BYTES = 64 * 1024 * 1024;