pi-subagents 0.41.0 → 0.42.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.
@@ -5,6 +5,131 @@ import { getAgentDir } from "./utils.ts";
5
5
  const CLEANUP_MARKER_FILE = ".last-cleanup";
6
6
  const PROJECT_ARTIFACT_ROOT = ".pi-subagents";
7
7
 
8
+ const PROJECT_ARTIFACT_PATHS = [
9
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/output.md`,
10
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_input.md`,
11
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_output.md`,
12
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker.jsonl`,
13
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_transcript.jsonl`,
14
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_meta.json`,
15
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/progress/run/progress.md`,
16
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/output.md`,
17
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/run/output.md`,
18
+ `${PROJECT_ARTIFACT_ROOT}/chain-runs/run.json`,
19
+ ];
20
+
21
+ function globMatchesPath(pattern: string, filePath: string): boolean {
22
+ let expression = "^";
23
+ for (let index = 0; index < pattern.length; index += 1) {
24
+ const character = pattern[index];
25
+ if (character === undefined) break;
26
+ if (character === "*" && pattern[index + 1] === "*") {
27
+ index += 1;
28
+ if (pattern[index + 1] === "/") {
29
+ index += 1;
30
+ expression += "(?:.*/)?";
31
+ } else {
32
+ expression += ".*";
33
+ }
34
+ } else if (character === "*") {
35
+ expression += "[^/]*";
36
+ } else if (character === "?") {
37
+ expression += "[^/]";
38
+ } else if (character === "[") {
39
+ const end = pattern.indexOf("]", index + 1);
40
+ if (end === -1) expression += "\\[";
41
+ else {
42
+ expression += pattern.slice(index, end + 1);
43
+ index = end;
44
+ }
45
+ } else {
46
+ expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
47
+ }
48
+ }
49
+ try {
50
+ return new RegExp(`${expression}$`).test(filePath);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function normalizePattern(pattern: string): string {
57
+ return pattern.replace(/^\.\//, "").replace(/^\//, "").replace(/\/+$/, "");
58
+ }
59
+
60
+ function patternMatchesArtifactPath(pattern: string, artifactPath: string): boolean {
61
+ const normalized = normalizePattern(pattern);
62
+ return normalized === PROJECT_ARTIFACT_ROOT
63
+ || normalized === "*"
64
+ || artifactPath.startsWith(`${normalized}/`)
65
+ || globMatchesPath(normalized, artifactPath);
66
+ }
67
+
68
+ function patternMatchesProjectArtifacts(pattern: string): boolean {
69
+ return PROJECT_ARTIFACT_PATHS.some((artifactPath) => patternMatchesArtifactPath(pattern, artifactPath));
70
+ }
71
+
72
+ function ignoreFileExcludesProjectArtifacts(filePath: string): boolean {
73
+ if (!fs.existsSync(filePath)) return false;
74
+ try {
75
+ const excluded = new Set<string>();
76
+ for (const rawLine of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
77
+ const line = rawLine.trim();
78
+ if (!line || line.startsWith("#")) continue;
79
+ const negated = line.startsWith("!");
80
+ const pattern = negated ? line.slice(1) : line;
81
+ for (const artifactPath of PROJECT_ARTIFACT_PATHS) {
82
+ if (!patternMatchesArtifactPath(pattern, artifactPath)) continue;
83
+ if (negated) excluded.delete(artifactPath);
84
+ else excluded.add(artifactPath);
85
+ }
86
+ }
87
+ return excluded.size === PROJECT_ARTIFACT_PATHS.length;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function filesIncludeProjectArtifacts(files: unknown): boolean | undefined {
94
+ if (!Array.isArray(files)) return undefined;
95
+ const included = new Set<string>();
96
+ for (const entry of files) {
97
+ if (typeof entry !== "string") continue;
98
+ const negated = entry.startsWith("!");
99
+ const pattern = negated ? entry.slice(1) : entry;
100
+ for (const artifactPath of PROJECT_ARTIFACT_PATHS) {
101
+ if (!patternMatchesArtifactPath(pattern, artifactPath)) continue;
102
+ if (negated) included.delete(artifactPath);
103
+ else included.add(artifactPath);
104
+ }
105
+ }
106
+ return included.size > 0;
107
+ }
108
+
109
+ /** Returns a package-publishing warning when project artifacts can enter npm packages. */
110
+ export function getProjectArtifactPackagingWarning(cwd: string): string | undefined {
111
+ const packagePath = path.join(cwd, "package.json");
112
+ if (!fs.existsSync(packagePath)) return undefined;
113
+
114
+ let packageJson: Record<string, unknown>;
115
+ try {
116
+ const parsed: unknown = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
117
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
118
+ packageJson = parsed as Record<string, unknown>;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+
123
+ const filesIncludeArtifacts = filesIncludeProjectArtifacts(packageJson.files);
124
+ if (filesIncludeArtifacts === false) return undefined;
125
+
126
+ const npmIgnorePath = path.join(cwd, ".npmignore");
127
+ const ignorePath = fs.existsSync(npmIgnorePath) ? npmIgnorePath : path.join(cwd, ".gitignore");
128
+ if (filesIncludeArtifacts === undefined && ignoreFileExcludesProjectArtifacts(ignorePath)) return undefined;
129
+
130
+ return "Project-scoped subagent artifacts can be included when this package is published. Add '.pi-subagents/' to .npmignore, restrict package.json files, or set artifactDir to 'session' or 'temp'.";
131
+ }
132
+
8
133
  export function getProjectSubagentsDir(cwd: string): string {
9
134
  return path.join(cwd, PROJECT_ARTIFACT_ROOT);
10
135
  }
@@ -1007,7 +1007,7 @@ export interface Details {
1007
1007
  console: Array<{ level: "log" | "info" | "warn" | "error"; text: string }>;
1008
1008
  };
1009
1009
  chatProgress?: {
1010
- mode: "off" | "terminal" | "milestones" | "live-card";
1010
+ mode: "off" | "live-card";
1011
1011
  repoRelation: "same" | "other";
1012
1012
  repoLabel?: string;
1013
1013
  };
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { keyText, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
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
7
  import {
8
8
  DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS,
@@ -281,7 +281,7 @@ class SubagentsStopSelector implements Component {
281
281
  invalidate(): void {}
282
282
 
283
283
  render(width: number): string[] {
284
- const contentWidth = Math.max(40, Math.min(this.width, width || this.width));
284
+ const contentWidth = Math.max(0, Math.min(this.width, Math.floor(width)));
285
285
  const lines = [this.theme.bold("Stop subagent run"), this.theme.fg("dim", "Select a current-session async run to stop, or a scheduled run to cancel."), ""];
286
286
  const maxRows = 10;
287
287
  const start = Math.max(0, Math.min(this.selected - maxRows + 1, Math.max(0, this.targets.length - maxRows)));
@@ -305,7 +305,7 @@ class SubagentsStopSelector implements Component {
305
305
  } else {
306
306
  lines.push(this.theme.fg("dim", "↑↓/jk select · Enter confirm · Esc cancel"));
307
307
  }
308
- return lines;
308
+ return lines.map((line) => truncateToWidth(line, contentWidth));
309
309
  }
310
310
  }
311
311
 
package/src/tui/fleet.ts CHANGED
@@ -780,7 +780,7 @@ export class SubagentFleetComponent implements Component {
780
780
  if (width < 36) return [truncateToWidth("Subagent fleet needs at least 36 columns. Esc closes.", width)];
781
781
  const innerWidth = width - 2;
782
782
  const rows = this.tui.terminal?.rows ?? 32;
783
- this.bodyHeight = Math.max(2, Math.min(30, Math.floor(rows * 0.85) - 6));
783
+ this.bodyHeight = Math.max(2, Math.floor(rows * 0.85) - 6);
784
784
  const rosterWidth = Math.max(22, Math.min(46, Math.floor((innerWidth - 1) * 0.38)));
785
785
  const detailWidth = Math.max(1, innerWidth - rosterWidth - 1);
786
786
  const roster = this.rosterLines(rosterWidth);
package/src/tui/render.ts CHANGED
@@ -144,6 +144,24 @@ function runningGlyph(seed?: number): string {
144
144
  return RUNNING_FRAMES[Math.abs(seed) % RUNNING_FRAMES.length]!;
145
145
  }
146
146
 
147
+ const WIDGET_ANIMATION_FRAME_MS = 500;
148
+ let widgetRequestRender: (() => void) | undefined;
149
+
150
+ function animatedSeed(seed: number | undefined, frame: number | undefined): number | undefined {
151
+ if (frame === undefined) return seed;
152
+ return (seed ?? 0) + frame;
153
+ }
154
+
155
+ function widgetAnimationFrame(): number {
156
+ return Math.floor(Date.now() / WIDGET_ANIMATION_FRAME_MS);
157
+ }
158
+
159
+ export function requestWidgetRender(): boolean {
160
+ if (!widgetRequestRender) return false;
161
+ widgetRequestRender();
162
+ return true;
163
+ }
164
+
147
165
  function progressRunningSeed(progress: ProgressSeedSource | undefined): number | undefined {
148
166
  if (!progress) return undefined;
149
167
  return runningSeed(
@@ -461,8 +479,8 @@ function widgetJobsRunningSeed(jobs: AsyncJobState[]): number | undefined {
461
479
  return seed;
462
480
  }
463
481
 
464
- function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
465
- if (job.status === "running") return theme.fg("accent", runningGlyph(widgetJobRunningSeed(job)));
482
+ function widgetStatusGlyph(job: AsyncJobState, theme: Theme, frame?: number): string {
483
+ if (job.status === "running") return theme.fg("accent", runningGlyph(animatedSeed(widgetJobRunningSeed(job), frame)));
466
484
  if (job.status === "queued") return theme.fg("muted", "◦");
467
485
  if (job.status === "complete") return theme.fg("success", "✓");
468
486
  if (job.status === "paused") return theme.fg("warning", "■");
@@ -470,8 +488,8 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
470
488
  return theme.fg("error", "✗");
471
489
  }
472
490
 
473
- function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number): string {
474
- if (status === "running") return theme.fg("accent", runningGlyph(seed));
491
+ function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number, frame?: number): string {
492
+ if (status === "running") return theme.fg("accent", runningGlyph(animatedSeed(seed, frame)));
475
493
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
476
494
  if (status === "failed") return theme.fg("error", "✗");
477
495
  if (status === "paused") return theme.fg("warning", "■");
@@ -1008,11 +1026,12 @@ function foregroundStyleWidgetStepLines(
1008
1026
  total: number,
1009
1027
  expanded: boolean,
1010
1028
  width: number,
1029
+ frame?: number,
1011
1030
  ): string[] {
1012
1031
  const status = widgetStepStatus(step.status, theme);
1013
1032
  const stats = widgetStepStats(theme, step);
1014
1033
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
1015
- const lines = [` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index - 1))} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
1034
+ const lines = [` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index - 1), frame)} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
1016
1035
  const activity = widgetStepActivityLine(step, width, expanded, job.updatedAt);
1017
1036
  if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`);
1018
1037
  for (const nestedLine of formatNestedWidgetLines(step.children, theme, width, expanded, job.updatedAt, expanded ? 12 : 6)) {
@@ -1038,7 +1057,7 @@ function foregroundStyleWidgetStepLines(
1038
1057
  return lines;
1039
1058
  }
1040
1059
 
1041
- function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number): string[] {
1060
+ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number, frame?: number): string[] {
1042
1061
  if (!job.steps?.length) return [
1043
1062
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1044
1063
  ...formatNestedWidgetLines(job.nestedChildren, theme, width, expanded, job.updatedAt, expanded ? 12 : 6).map((line) => ` ${line}`),
@@ -1048,7 +1067,7 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
1048
1067
  const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
1049
1068
  const lines: string[] = [];
1050
1069
  for (const [index, step] of job.steps.entries()) {
1051
- lines.push(...foregroundStyleWidgetStepLines(job, theme, step, itemTitle, index + 1, total, expanded, width));
1070
+ lines.push(...foregroundStyleWidgetStepLines(job, theme, step, itemTitle, index + 1, total, expanded, width, frame));
1052
1071
  }
1053
1072
  const attached = new Set(job.steps.flatMap((step) => step.children?.map((child) => child.id) ?? []));
1054
1073
  const unattached = job.nestedChildren?.filter((child) => !attached.has(child.id)) ?? [];
@@ -1058,20 +1077,20 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
1058
1077
  return lines;
1059
1078
  }
1060
1079
 
1061
- function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean): string[] {
1080
+ function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
1062
1081
  const stats = widgetStats(job, theme);
1063
1082
  const count = job.mode === "chain" ? job.chainStepCount : job.stepsTotal ?? job.agents?.length ?? job.steps?.length;
1064
1083
  const mode = widgetJobName(job);
1065
1084
  const title = `async subagent ${mode}${count && count > 1 ? ` (${count})` : ""}`;
1066
1085
  return [
1067
1086
  `${theme.fg("toolTitle", themeBold(theme, title))} ${theme.fg("dim", "· background")}`,
1068
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, mode)}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1069
- ...foregroundStyleWidgetDetails(job, theme, expanded, width),
1087
+ `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, mode)}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1088
+ ...foregroundStyleWidgetDetails(job, theme, expanded, width, frame),
1070
1089
  ].map((line) => truncLine(line, width));
1071
1090
  }
1072
1091
 
1073
- function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number): string[] {
1074
- const fullLines = buildSingleWidgetLines(job, theme, width, false);
1092
+ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, frame?: number): string[] {
1093
+ const fullLines = buildSingleWidgetLines(job, theme, width, false, frame);
1075
1094
  if (fullLines.length <= 10 || !job.steps?.length || (job.mode !== "parallel" && !job.activeParallelGroup)) return fullLines;
1076
1095
 
1077
1096
  const total = job.stepsTotal ?? job.steps.length;
@@ -1083,7 +1102,7 @@ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: numbe
1083
1102
  const stepStats = widgetStepStats(theme, step);
1084
1103
  const activitySuffix = activity ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` : "";
1085
1104
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
1086
- lines.push(` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index))} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
1105
+ lines.push(` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index), frame)} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
1087
1106
  for (const nestedLine of formatNestedWidgetLines(step.children, theme, width, false, job.updatedAt, 6)) lines.push(` ${nestedLine}`);
1088
1107
  }
1089
1108
  if (job.steps.some((step) => step.status === "running")) lines.push(theme.fg("accent", ` ${liveDetailHintText()}`));
@@ -1139,10 +1158,10 @@ function widgetHeaderCounts(jobs: AsyncJobState[]): { running: AsyncJobState[];
1139
1158
  };
1140
1159
  }
1141
1160
 
1142
- function buildSingleLineWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number): string[] {
1161
+ function buildSingleLineWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, frame?: number): string[] {
1143
1162
  const counts = widgetHeaderCounts(jobs);
1144
1163
  const hasActive = counts.running.length > 0 || counts.queued.length > 0;
1145
- const glyph = counts.running.length > 0 ? runningGlyph(widgetJobsRunningSeed(counts.running)) : hasActive ? "●" : "○";
1164
+ const glyph = counts.running.length > 0 ? runningGlyph(animatedSeed(widgetJobsRunningSeed(counts.running), frame)) : hasActive ? "●" : "○";
1146
1165
  const parts: string[] = [];
1147
1166
  if (counts.running.length > 0) parts.push(`${counts.running.length}/${jobs.length} running`);
1148
1167
  if (counts.queued.length > 0) parts.push(`${counts.queued.length} queued`);
@@ -1202,10 +1221,10 @@ function selectProgressiveJobKeys(jobs: AsyncJobState[], previousKeys: string[],
1202
1221
  return selected;
1203
1222
  }
1204
1223
 
1205
- function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: number): string {
1224
+ function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: number, frame?: number): string {
1206
1225
  const counts = widgetHeaderCounts(jobs);
1207
1226
  const hasActive = counts.running.length > 0 || counts.queued.length > 0;
1208
- const glyph = counts.running.length > 0 ? runningGlyph(widgetJobsRunningSeed(counts.running)) : hasActive ? "●" : "○";
1227
+ const glyph = counts.running.length > 0 ? runningGlyph(animatedSeed(widgetJobsRunningSeed(counts.running), frame)) : hasActive ? "●" : "○";
1209
1228
  const parts: string[] = [];
1210
1229
  if (counts.running.length > 0) parts.push(formatAgentRunningLabel(counts.running.length));
1211
1230
  if (counts.queued.length > 0) parts.push(`${counts.queued.length} queued`);
@@ -1218,7 +1237,7 @@ function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: numbe
1218
1237
  return truncLine(`${theme.fg(hasActive ? "accent" : "dim", glyph)} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(", ") || `${jobs.length} total`)}`, width);
1219
1238
  }
1220
1239
 
1221
- function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number): string {
1240
+ function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number, frame?: number): string {
1222
1241
  const stats = widgetStats(job, theme);
1223
1242
  const activity = widgetActivity(job);
1224
1243
  const status = job.status === "complete" ? "done" : job.status;
@@ -1228,7 +1247,7 @@ function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number): st
1228
1247
  stats,
1229
1248
  activity && activity.toLowerCase() !== status ? theme.fg("dim", activity) : "",
1230
1249
  ].filter(Boolean);
1231
- return truncLine(` ${widgetStatusGlyph(job, theme)} ${parts.join(` ${theme.fg("dim", "·")} `)}`, width);
1250
+ return truncLine(` ${widgetStatusGlyph(job, theme, frame)} ${parts.join(` ${theme.fg("dim", "·")} `)}`, width);
1232
1251
  }
1233
1252
 
1234
1253
  function progressiveHiddenLine(hiddenJobs: AsyncJobState[], theme: Theme, width: number): string {
@@ -1241,9 +1260,9 @@ function progressiveHiddenLine(hiddenJobs: AsyncJobState[], theme: Theme, width:
1241
1260
  return truncLine(theme.fg("dim", ` +${hiddenJobs.length} more${parts.length ? ` (${parts.join(", ")})` : ""}`), width);
1242
1261
  }
1243
1262
 
1244
- function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, lockedRows: number, previousKeys: string[]): { lines: string[]; visibleJobKeys: string[] } {
1263
+ function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, lockedRows: number, previousKeys: string[], frame?: number): { lines: string[]; visibleJobKeys: string[] } {
1245
1264
  const rowCount = Math.max(1, lockedRows);
1246
- if (rowCount === 1) return { lines: buildSingleLineWidgetLines(jobs, theme, width), visibleJobKeys: [] };
1265
+ if (rowCount === 1) return { lines: buildSingleLineWidgetLines(jobs, theme, width, frame), visibleJobKeys: [] };
1247
1266
 
1248
1267
  const bodyRows = rowCount - 1;
1249
1268
  let visibleJobKeys = selectProgressiveJobKeys(jobs, previousKeys, bodyRows);
@@ -1259,8 +1278,8 @@ function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width:
1259
1278
  }
1260
1279
 
1261
1280
  const lines = [
1262
- progressiveHeaderLine(jobs, theme, width),
1263
- ...visibleJobs.map((job) => progressiveJobLine(job, theme, width)),
1281
+ progressiveHeaderLine(jobs, theme, width, frame),
1282
+ ...visibleJobs.map((job) => progressiveJobLine(job, theme, width, frame)),
1264
1283
  ];
1265
1284
  if (hiddenJobs.length > 0 && lines.length < rowCount) lines.push(progressiveHiddenLine(hiddenJobs, theme, width));
1266
1285
  while (lines.length < rowCount) lines.push(" ");
@@ -1271,6 +1290,12 @@ function collapsedWidgetLineBudget(rows: number): number {
1271
1290
  return Math.max(10, Math.min(14, Math.floor(rows * 0.35)));
1272
1291
  }
1273
1292
 
1293
+ function paddedWidgetLine(line: string, width: number): string {
1294
+ if (width <= 2) return " ".repeat(Math.max(0, width));
1295
+ const text = ` ${truncLine(line, width - 2)} `;
1296
+ return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
1297
+ }
1298
+
1274
1299
  function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expanded: boolean): string[] {
1275
1300
  const rows = process.stdout.rows || 30;
1276
1301
  const budget = expanded
@@ -1285,7 +1310,7 @@ function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expan
1285
1310
  return [...lines.slice(0, visibleLines), truncLine(theme.fg("dim", hint), width)];
1286
1311
  }
1287
1312
 
1288
- function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: Theme, width: number, expanded: boolean): string[] {
1313
+ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
1289
1314
  if (expanded) {
1290
1315
  resetWidgetLayoutSession();
1291
1316
  return fitWidgetLineBudget(lines, theme, width, true);
@@ -1297,11 +1322,11 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: T
1297
1322
  const availableRows = estimateAvailableWidgetRows();
1298
1323
 
1299
1324
  if (hasMatchingSession && widgetLayoutSession?.tier === "single-line") {
1300
- return buildSingleLineWidgetLines(jobs, theme, width);
1325
+ return buildSingleLineWidgetLines(jobs, theme, width, frame);
1301
1326
  }
1302
1327
 
1303
1328
  if (hasMatchingSession && widgetLayoutSession?.tier === "progressive" && widgetLayoutSession.lockedRows !== undefined) {
1304
- const rendered = buildProgressiveWidgetLines(jobs, theme, width, widgetLayoutSession.lockedRows, widgetLayoutSession.visibleJobKeys);
1329
+ const rendered = buildProgressiveWidgetLines(jobs, theme, width, widgetLayoutSession.lockedRows, widgetLayoutSession.visibleJobKeys, frame);
1305
1330
  widgetLayoutSession.visibleJobKeys = rendered.visibleJobKeys;
1306
1331
  return rendered.lines;
1307
1332
  }
@@ -1313,39 +1338,48 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: T
1313
1338
 
1314
1339
  if (availableRows <= 2) {
1315
1340
  widgetLayoutSession = { expanded, rows, columns, tier: "single-line", visibleJobKeys: [] };
1316
- return buildSingleLineWidgetLines(jobs, theme, width);
1341
+ return buildSingleLineWidgetLines(jobs, theme, width, frame);
1317
1342
  }
1318
1343
 
1319
1344
  const lockedRows = Math.min(availableRows, collapsedWidgetLineBudget(rows));
1320
- const rendered = buildProgressiveWidgetLines(jobs, theme, width, lockedRows, []);
1345
+ const rendered = buildProgressiveWidgetLines(jobs, theme, width, lockedRows, [], frame);
1321
1346
  widgetLayoutSession = { expanded, rows, columns, tier: "progressive", lockedRows, visibleJobKeys: rendered.visibleJobKeys };
1322
1347
  return rendered.lines;
1323
1348
  }
1324
1349
 
1325
- function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (_tui: unknown, theme: Theme) => Component {
1326
- return (_tui, theme) => {
1327
- const width = getTermWidth();
1328
- const lines = expanded
1329
- ? buildWidgetLines(jobs, theme, width, true)
1330
- : jobs.length === 1
1331
- ? compactSingleWidgetLines(jobs[0]!, theme, width)
1332
- : buildWidgetLines(jobs, theme, width, false);
1350
+ function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (tui: { requestRender(): void }, theme: Theme) => Component & { dispose?(): void } {
1351
+ return (tui, theme) => {
1352
+ const requestRender = () => tui.requestRender();
1353
+ widgetRequestRender = requestRender;
1333
1354
  const container = new Container();
1334
- for (const line of fitAdaptiveWidgetLines(jobs, lines, theme, width, expanded)) container.addChild(new Text(line, 1, 0));
1335
- return container;
1355
+ container.render = (renderWidth: number): string[] => {
1356
+ const width = Math.max(0, renderWidth - 2);
1357
+ const frame = widgetAnimationFrame();
1358
+ const lines = expanded
1359
+ ? buildWidgetLines(jobs, theme, width, true, frame)
1360
+ : jobs.length === 1
1361
+ ? compactSingleWidgetLines(jobs[0]!, theme, width, frame)
1362
+ : buildWidgetLines(jobs, theme, width, false, frame);
1363
+ return fitAdaptiveWidgetLines(jobs, lines, theme, width, expanded, frame).map((line) => paddedWidgetLine(line, renderWidth));
1364
+ };
1365
+ const component = container as Component & { dispose?(): void };
1366
+ component.dispose = () => {
1367
+ if (widgetRequestRender === requestRender) widgetRequestRender = undefined;
1368
+ };
1369
+ return component;
1336
1370
  };
1337
1371
  }
1338
1372
 
1339
- export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false): string[] {
1373
+ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false, frame?: number): string[] {
1340
1374
  if (jobs.length === 0) return [];
1341
- if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded);
1375
+ if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded, frame);
1342
1376
  const running = jobs.filter((job) => job.status === "running");
1343
1377
  const queued = jobs.filter((job) => job.status === "queued");
1344
1378
  const finished = jobs.filter((job) => job.status !== "running" && job.status !== "queued");
1345
1379
 
1346
1380
  const lines: string[] = [];
1347
1381
  const hasActive = running.length > 0 || queued.length > 0;
1348
- const headerGlyph = running.length > 0 ? runningGlyph(widgetJobsRunningSeed(running)) : hasActive ? "●" : "○";
1382
+ const headerGlyph = running.length > 0 ? runningGlyph(animatedSeed(widgetJobsRunningSeed(running), frame)) : hasActive ? "●" : "○";
1349
1383
  lines.push(truncLine(`${theme.fg(hasActive ? "accent" : "dim", headerGlyph)} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "· background")}`, width));
1350
1384
 
1351
1385
  const items: string[][] = [];
@@ -1358,7 +1392,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1358
1392
  if (slots <= 0) { hiddenRunning++; continue; }
1359
1393
  const stats = widgetStats(job, theme);
1360
1394
  items.push([
1361
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1395
+ `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1362
1396
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1363
1397
  ...widgetParallelAgentDetails(job, theme, expanded, width),
1364
1398
  ]);
@@ -1375,7 +1409,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1375
1409
  if (slots <= 0) { hiddenFinished++; continue; }
1376
1410
  const stats = widgetStats(job, theme);
1377
1411
  items.push([
1378
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1412
+ `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1379
1413
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1380
1414
  ...widgetParallelAgentDetails(job, theme, expanded, width),
1381
1415
  ]);
@@ -1412,6 +1446,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1412
1446
  export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
1413
1447
  if (jobs.length === 0) {
1414
1448
  resetWidgetLayoutSession();
1449
+ widgetRequestRender = undefined;
1415
1450
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
1416
1451
  return;
1417
1452
  }
@@ -5,10 +5,4 @@ declare module "@earendil-works/pi-agent-core" {
5
5
  }
6
6
  }
7
7
 
8
- declare module "@earendil-works/pi-coding-agent" {
9
- interface ExtensionUIContext {
10
- requestRender?: () => void;
11
- }
12
- }
13
-
14
8
  export {};
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { Details } from "../shared/types.ts";
5
5
 
6
- export const WORKFLOW_CHAT_PROGRESS_MODES = ["auto", "off", "terminal", "milestones", "live-card"] as const;
6
+ export const WORKFLOW_CHAT_PROGRESS_MODES = ["auto", "off", "live-card"] as const;
7
7
  export type WorkflowChatProgressMode = typeof WORKFLOW_CHAT_PROGRESS_MODES[number];
8
8
  export type ResolvedWorkflowChatProgressMode = Exclude<WorkflowChatProgressMode, "auto">;
9
9
 
@@ -86,7 +86,7 @@ export function resolveWorkflowChatProgress(input: ResolveWorkflowChatProgressIn
86
86
 
87
87
  const requestedMode = requested.mode ?? "auto";
88
88
  let mode: ResolvedWorkflowChatProgressMode;
89
- if (requestedMode === "auto") mode = sameRepo ? (input.background ? "milestones" : "live-card") : "terminal";
89
+ if (requestedMode === "auto") mode = sameRepo && !input.background ? "live-card" : "off";
90
90
  else mode = requestedMode;
91
91
 
92
92
  if (mode === "live-card" && !sameRepo) return { error: "chatProgress: 'live-card' is only available for workflowScript runs in the same Git repository." };
@@ -175,7 +175,7 @@ export class WorkflowScriptError extends Error {
175
175
 
176
176
  export interface RunWorkflowScriptOptions {
177
177
  script: string;
178
- timeoutMs: number;
178
+ timeoutMs?: number;
179
179
  signal?: AbortSignal;
180
180
  launch: (key: string, params: Record<string, unknown>, signal: AbortSignal) => Promise<WorkflowScriptChildResult>;
181
181
  status: (keyOrRunId: string, signal: AbortSignal) => Promise<WorkflowScriptChildResult>;
@@ -261,7 +261,7 @@ function workflowStringMetadata(params: Record<string, unknown>): Pick<WorkflowS
261
261
 
262
262
  export async function runWorkflowScript(options: RunWorkflowScriptOptions): Promise<WorkflowScriptResult> {
263
263
  if (!options.script.trim()) throw new Error("workflowScript must not be empty.");
264
- if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1) throw new Error("workflow script timeout must be a positive integer.");
264
+ if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1)) throw new Error("workflow script timeout must be a positive integer.");
265
265
 
266
266
  const worker = new Worker(WORKER_SOURCE, { eval: true });
267
267
  const emits: unknown[] = [];
@@ -283,7 +283,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
283
283
  const finish = (outcome: { value: unknown } | { error: Error }) => {
284
284
  if (settled) return;
285
285
  settled = true;
286
- clearTimeout(timer);
286
+ if (timer) clearTimeout(timer);
287
287
  options.signal?.removeEventListener("abort", onAbort);
288
288
  void worker.terminate();
289
289
  childController.abort("error" in outcome ? outcome.error : new Error("Workflow script completed; unawaited child launches are aborted."));
@@ -291,7 +291,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
291
291
  else resolve({ value: outcome.value, ...partial() });
292
292
  };
293
293
  const onAbort = () => finish({ error: new Error("Workflow script aborted.") });
294
- const timer = setTimeout(() => finish({ error: new Error(`Workflow script timed out after ${options.timeoutMs}ms.`) }), options.timeoutMs);
294
+ const timer = options.timeoutMs === undefined
295
+ ? undefined
296
+ : setTimeout(() => finish({ error: new Error(`Workflow script timed out after ${options.timeoutMs}ms.`) }), options.timeoutMs);
295
297
  options.signal?.addEventListener("abort", onAbort, { once: true });
296
298
  if (options.signal?.aborted) return onAbort();
297
299