pi-subagents 0.18.0 → 0.19.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.
package/render.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  WIDGET_KEY,
14
14
  } from "./types.ts";
15
15
  import { formatTokens, formatUsage, formatDuration, formatToolCall, shortenPath } from "./formatters.ts";
16
- import { getDisplayItems, getLastActivity, getOutputTail, getSingleResultOutput } from "./utils.ts";
16
+ import { getDisplayItems, getLastActivity, getSingleResultOutput } from "./utils.ts";
17
17
 
18
18
  type Theme = ExtensionContext["ui"]["theme"];
19
19
 
@@ -79,12 +79,49 @@ function truncLine(text: string, maxWidth: number): string {
79
79
  return result + activeStyles.join("") + "…";
80
80
  }
81
81
 
82
- let lastWidgetHash = "";
82
+ const SPINNER = ["", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
83
+ const WIDGET_ANIMATION_MS = 80;
83
84
 
84
- function computeWidgetHash(jobs: AsyncJobState[]): string {
85
- return jobs.slice(0, MAX_WIDGET_JOBS).map(job =>
86
- `${job.asyncId}:${job.status}:${job.activityState}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
87
- ).join("|");
85
+ let widgetTimer: ReturnType<typeof setInterval> | undefined;
86
+ let latestWidgetCtx: ExtensionContext | undefined;
87
+ let latestWidgetJobs: AsyncJobState[] = [];
88
+
89
+ const resultAnimationTimers = new Map<ReturnType<typeof setInterval>, ResultAnimationContext["state"]>();
90
+ const outputActivityCache = new Map<string, { checkedAt: number; text: string }>();
91
+
92
+ export interface ResultAnimationContext {
93
+ state: { subagentResultAnimationTimer?: ReturnType<typeof setInterval> };
94
+ invalidate: () => void;
95
+ }
96
+
97
+ function spinnerFrame(): string {
98
+ return SPINNER[Math.floor(Date.now() / WIDGET_ANIMATION_MS) % SPINNER.length]!;
99
+ }
100
+
101
+ function resultIsRunning(result: AgentToolResult<Details>): boolean {
102
+ return result.details?.progress?.some((entry) => entry.status === "running")
103
+ || result.details?.results.some((entry) => entry.progress?.status === "running")
104
+ || false;
105
+ }
106
+
107
+ function stopResultAnimation(context: ResultAnimationContext): void {
108
+ const timer = context.state.subagentResultAnimationTimer;
109
+ if (!timer) return;
110
+ clearInterval(timer);
111
+ resultAnimationTimers.delete(timer);
112
+ context.state.subagentResultAnimationTimer = undefined;
113
+ }
114
+
115
+ export function syncResultAnimation(result: AgentToolResult<Details>, context: ResultAnimationContext): void {
116
+ if (!resultIsRunning(result)) {
117
+ stopResultAnimation(context);
118
+ return;
119
+ }
120
+ if (context.state.subagentResultAnimationTimer) return;
121
+ const timer = setInterval(() => context.invalidate(), WIDGET_ANIMATION_MS);
122
+ timer.unref?.();
123
+ context.state.subagentResultAnimationTimer = timer;
124
+ resultAnimationTimers.set(timer, context.state);
88
125
  }
89
126
 
90
127
  function extractOutputTarget(task: string): string | undefined {
@@ -146,67 +183,340 @@ function buildLiveStatusLine(progress: Pick<AgentProgress, "activityState" | "la
146
183
  return formatActivityLabel(progress.lastActivityAt, progress.activityState === "needs_attention");
147
184
  }
148
185
 
186
+ function themeBold(theme: Theme, text: string): string {
187
+ return ((theme as { bold?: (value: string) => string }).bold?.(text)) ?? text;
188
+ }
189
+
190
+ function statJoin(theme: Theme, parts: string[]): string {
191
+ return parts.filter(Boolean).map((part) => theme.fg("dim", part)).join(` ${theme.fg("dim", "·")} `);
192
+ }
193
+
194
+ function formatTokenStat(tokens: number): string {
195
+ return `${formatTokens(tokens)} token`;
196
+ }
197
+
198
+ function formatToolUseStat(count: number): string {
199
+ return `${count} tool use${count === 1 ? "" : "s"}`;
200
+ }
201
+
202
+ function formatProgressStats(theme: Theme, progress: Pick<AgentProgress, "toolCount" | "tokens" | "durationMs"> | undefined, includeDuration = true): string {
203
+ if (!progress) return "";
204
+ const parts: string[] = [];
205
+ if (progress.toolCount > 0) parts.push(formatToolUseStat(progress.toolCount));
206
+ if (progress.tokens > 0) parts.push(formatTokenStat(progress.tokens));
207
+ if (includeDuration && progress.durationMs > 0) parts.push(formatDuration(progress.durationMs));
208
+ return statJoin(theme, parts);
209
+ }
210
+
211
+ function firstOutputLine(text: string): string {
212
+ return text.split("\n").find((line) => line.trim())?.trim() ?? "";
213
+ }
214
+
215
+ function resultStatusLine(result: Details["results"][number], output: string): string {
216
+ if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached";
217
+ if (result.interrupted) return "Paused";
218
+ if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`;
219
+ if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return "Done (no text output)";
220
+ return "Done";
221
+ }
222
+
223
+ function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running"): string {
224
+ if (running) return theme.fg("accent", spinnerFrame());
225
+ if (result.detached) return theme.fg("warning", "■");
226
+ if (result.interrupted) return theme.fg("warning", "■");
227
+ if (result.exitCode !== 0) return theme.fg("error", "✗");
228
+ if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return theme.fg("warning", "✓");
229
+ return theme.fg("success", "✓");
230
+ }
231
+
232
+ function compactCurrentActivity(progress: AgentProgress): string {
233
+ return formatCurrentToolLine(progress, getTermWidth() - 4, false) ?? buildLiveStatusLine(progress) ?? "thinking…";
234
+ }
235
+
236
+ function hasAnimatedWidgetJobs(jobs: AsyncJobState[]): boolean {
237
+ return jobs.some((job) => job.status === "running");
238
+ }
239
+
240
+ function widgetJobName(job: AsyncJobState): string {
241
+ if (job.agents?.length) return job.agents.join(" → ");
242
+ return job.mode ?? "subagent";
243
+ }
244
+
245
+ function getCachedLastActivity(outputFile: string | undefined): string {
246
+ if (!outputFile) return "";
247
+ const now = Date.now();
248
+ const cached = outputActivityCache.get(outputFile);
249
+ if (cached && now - cached.checkedAt < 1000) return cached.text;
250
+ const text = getLastActivity(outputFile);
251
+ outputActivityCache.set(outputFile, { checkedAt: now, text });
252
+ return text;
253
+ }
254
+
255
+ function widgetActivity(job: AsyncJobState): string {
256
+ if (job.currentTool && job.currentToolStartedAt !== undefined) {
257
+ return `${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`;
258
+ }
259
+ const activity = formatActivityLabel(job.lastActivityAt, job.activityState === "needs_attention")
260
+ ?? (job.status === "running" ? getCachedLastActivity(job.outputFile) : "");
261
+ if (activity) return activity;
262
+ if (job.status === "queued") return "queued…";
263
+ if (job.status === "paused") return "Paused";
264
+ if (job.status === "failed") return "Failed";
265
+ return "Done";
266
+ }
267
+
268
+ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
269
+ if (job.status === "running") return theme.fg("accent", spinnerFrame());
270
+ if (job.status === "queued") return theme.fg("muted", "◦");
271
+ if (job.status === "complete") return theme.fg("success", "✓");
272
+ if (job.status === "paused") return theme.fg("warning", "■");
273
+ return theme.fg("error", "✗");
274
+ }
275
+
276
+ function widgetStats(job: AsyncJobState, theme: Theme): string {
277
+ const parts: string[] = [];
278
+ const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1);
279
+ if (job.currentStep !== undefined) parts.push(`step ${job.currentStep + 1}/${stepsTotal}`);
280
+ else if (stepsTotal > 1) parts.push(`steps ${stepsTotal}`);
281
+ if (job.totalTokens?.total) parts.push(formatTokenStat(job.totalTokens.total));
282
+ const endTime = job.status === "complete" || job.status === "failed" || job.status === "paused" ? (job.updatedAt ?? Date.now()) : Date.now();
283
+ if (job.startedAt) parts.push(formatDuration(Math.max(0, endTime - job.startedAt)));
284
+ return statJoin(theme, parts);
285
+ }
286
+
287
+ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth()): string[] {
288
+ if (jobs.length === 0) return [];
289
+ const running = jobs.filter((job) => job.status === "running");
290
+ const queued = jobs.filter((job) => job.status === "queued");
291
+ const finished = jobs.filter((job) => job.status !== "running" && job.status !== "queued");
292
+
293
+ const lines: string[] = [];
294
+ const hasActive = running.length > 0 || queued.length > 0;
295
+ lines.push(truncLine(`${theme.fg(hasActive ? "accent" : "dim", hasActive ? "●" : "○")} ${theme.fg(hasActive ? "accent" : "dim", "Agents")} ${theme.fg("dim", "· /subagents-status")}`, width));
296
+
297
+ const items: string[][] = [];
298
+ let hiddenRunning = 0;
299
+ let hiddenFinished = 0;
300
+ let queuedSummaryShown = false;
301
+ let slots = MAX_WIDGET_JOBS;
302
+
303
+ for (const job of running) {
304
+ if (slots <= 0) { hiddenRunning++; continue; }
305
+ const stats = widgetStats(job, theme);
306
+ items.push([
307
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
308
+ ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
309
+ ]);
310
+ slots--;
311
+ }
312
+
313
+ if (queued.length > 0 && slots > 0) {
314
+ items.push([`${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`]);
315
+ queuedSummaryShown = true;
316
+ slots--;
317
+ }
318
+
319
+ for (const job of finished) {
320
+ if (slots <= 0) { hiddenFinished++; continue; }
321
+ const stats = widgetStats(job, theme);
322
+ items.push([
323
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
324
+ ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
325
+ ]);
326
+ slots--;
327
+ }
328
+
329
+ const hiddenQueued = queued.length > 0 && !queuedSummaryShown ? queued.length : 0;
330
+ const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued;
331
+ if (hiddenTotal > 0) {
332
+ const parts: string[] = [];
333
+ if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
334
+ if (hiddenQueued > 0) parts.push(`${hiddenQueued} queued`);
335
+ if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
336
+ items.push([theme.fg("dim", `+${hiddenTotal} more (${parts.join(", ")})`)]);
337
+ }
338
+
339
+ for (let i = 0; i < items.length; i++) {
340
+ const item = items[i]!;
341
+ const last = i === items.length - 1;
342
+ const branch = last ? "└─" : "├─";
343
+ const continuation = last ? " " : "│ ";
344
+ lines.push(truncLine(`${theme.fg("dim", branch)} ${item[0]}`, width));
345
+ for (const detail of item.slice(1)) {
346
+ lines.push(truncLine(`${theme.fg("dim", continuation)} ${detail}`, width));
347
+ }
348
+ }
349
+
350
+ return lines;
351
+ }
352
+
353
+ function refreshAnimatedWidget(): void {
354
+ if (!latestWidgetCtx?.hasUI || latestWidgetJobs.length === 0) return;
355
+ latestWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetLines(latestWidgetJobs, latestWidgetCtx.ui.theme));
356
+ latestWidgetCtx.ui.requestRender?.();
357
+ }
358
+
359
+ function ensureWidgetAnimation(): void {
360
+ if (widgetTimer) return;
361
+ widgetTimer = setInterval(() => {
362
+ if (!hasAnimatedWidgetJobs(latestWidgetJobs)) {
363
+ stopWidgetAnimation();
364
+ return;
365
+ }
366
+ refreshAnimatedWidget();
367
+ }, WIDGET_ANIMATION_MS);
368
+ widgetTimer.unref?.();
369
+ }
370
+
371
+ export function stopWidgetAnimation(): void {
372
+ if (widgetTimer) {
373
+ clearInterval(widgetTimer);
374
+ widgetTimer = undefined;
375
+ }
376
+ latestWidgetCtx = undefined;
377
+ latestWidgetJobs = [];
378
+ outputActivityCache.clear();
379
+ }
380
+
381
+ export function stopResultAnimations(): void {
382
+ for (const [timer, state] of resultAnimationTimers) {
383
+ clearInterval(timer);
384
+ state.subagentResultAnimationTimer = undefined;
385
+ }
386
+ resultAnimationTimers.clear();
387
+ }
388
+
149
389
  /**
150
390
  * Render the async jobs widget
151
391
  */
152
392
  export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
153
- if (!ctx.hasUI) return;
154
393
  if (jobs.length === 0) {
155
- if (lastWidgetHash !== "") {
156
- lastWidgetHash = "";
157
- ctx.ui.setWidget(WIDGET_KEY, undefined);
158
- }
394
+ stopWidgetAnimation();
395
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
159
396
  return;
160
397
  }
161
-
162
- const displayedJobs = jobs.slice(0, MAX_WIDGET_JOBS);
163
- const hasRunningJobs = displayedJobs.some(job => job.status === "running");
164
- const newHash = computeWidgetHash(jobs);
165
- if (!hasRunningJobs && newHash === lastWidgetHash) {
398
+ if (!ctx.hasUI) {
399
+ stopWidgetAnimation();
166
400
  return;
167
401
  }
168
- lastWidgetHash = newHash;
402
+ latestWidgetCtx = ctx;
403
+ latestWidgetJobs = [...jobs];
169
404
 
170
- const theme = ctx.ui.theme;
171
- const w = getTermWidth();
172
- const lines: string[] = [];
173
- lines.push(theme.fg("accent", "Async subagents"));
174
-
175
- for (const job of displayedJobs) {
176
- const id = job.asyncId.slice(0, 6);
177
- const status =
178
- job.status === "complete"
179
- ? theme.fg("success", "complete")
180
- : job.status === "failed"
181
- ? theme.fg("error", "failed")
182
- : job.status === "paused"
183
- ? theme.fg("warning", "paused")
184
- : theme.fg("warning", "running");
185
-
186
- const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1);
187
- const stepIndex = job.currentStep !== undefined ? job.currentStep + 1 : undefined;
188
- const stepText = stepIndex !== undefined ? `step ${stepIndex}/${stepsTotal}` : `steps ${stepsTotal}`;
189
- const endTime = (job.status === "complete" || job.status === "failed") ? (job.updatedAt ?? Date.now()) : Date.now();
190
- const elapsed = job.startedAt ? formatDuration(endTime - job.startedAt) : "";
191
- const agentLabel = job.agents ? job.agents.join(" -> ") : (job.mode ?? "single");
192
-
193
- const tokenText = job.totalTokens ? ` | ${formatTokens(job.totalTokens.total)} tok` : "";
194
- const activityText = job.currentTool && job.currentToolStartedAt
195
- ? `tool ${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`
196
- : formatActivityLabel(job.lastActivityAt, job.activityState === "needs_attention") ?? (job.status === "running" ? getLastActivity(job.outputFile) : "");
197
- const activitySuffix = activityText ? ` | ${theme.fg("dim", activityText)}` : "";
198
-
199
- lines.push(truncLine(`- ${id} ${status} | ${agentLabel} | ${stepText}${elapsed ? ` | ${elapsed}` : ""}${tokenText}${activitySuffix}`, w));
200
-
201
- if ((job.status === "running" || job.status === "paused") && job.outputFile) {
202
- const tail = getOutputTail(job.outputFile, 3);
203
- for (const line of tail) {
204
- lines.push(truncLine(theme.fg("dim", ` > ${line}`), w));
205
- }
405
+ ctx.ui.setWidget(WIDGET_KEY, buildWidgetLines(jobs, ctx.ui.theme));
406
+ if (hasAnimatedWidgetJobs(jobs)) ensureWidgetAnimation();
407
+ else stopWidgetAnimation();
408
+ }
409
+
410
+ function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme): Component {
411
+ const output = r.truncation?.text || getSingleResultOutput(r);
412
+ const progress = r.progress || r.progressSummary;
413
+ const isRunning = r.progress?.status === "running";
414
+ const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
415
+ const stats = statJoin(theme, [
416
+ r.usage?.turns ? `⟳${r.usage.turns}` : "",
417
+ formatProgressStats(theme, progress),
418
+ ]);
419
+ const c = new Container();
420
+ const width = getTermWidth() - 4;
421
+ c.addChild(new Text(truncLine(`${resultGlyph(r, output, theme, isRunning)} ${theme.fg("toolTitle", theme.bold(r.agent))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
422
+
423
+ if (isRunning && r.progress) {
424
+ const activity = compactCurrentActivity(r.progress);
425
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
426
+ const liveStatus = buildLiveStatusLine(r.progress);
427
+ if (liveStatus && liveStatus !== activity) c.addChild(new Text(truncLine(theme.fg("dim", ` ${liveStatus}`), width), 0, 0));
428
+ c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
429
+ if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
430
+ return c;
431
+ }
432
+
433
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
434
+ const preview = firstOutputLine(output);
435
+ if (preview && r.exitCode === 0 && !hasEmptyTextOutputWithoutOutputTarget(r.task, output)) {
436
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ${preview}`), width), 0, 0));
437
+ }
438
+ if (r.sessionFile) c.addChild(new Text(truncLine(theme.fg("dim", ` session: ${shortenPath(r.sessionFile)}`), width), 0, 0));
439
+ if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
440
+ if (r.truncation?.artifactPath) c.addChild(new Text(truncLine(theme.fg("dim", ` full output: ${shortenPath(r.truncation.artifactPath)}`), width), 0, 0));
441
+ return c;
442
+ }
443
+
444
+ function renderMultiCompact(d: Details, theme: Theme): Component {
445
+ const hasRunning = d.progress?.some((p) => p.status === "running")
446
+ || d.results.some((r) => r.progress?.status === "running");
447
+ const ok = d.results.filter((r) =>
448
+ !r.interrupted
449
+ && !r.detached
450
+ && (r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running" && r.progress?.status !== "pending"))
451
+ ).length;
452
+ const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running");
453
+ const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running");
454
+ let totalSummary = d.progressSummary;
455
+ if (!totalSummary) {
456
+ let sawProgress = false;
457
+ const summary = { toolCount: 0, tokens: 0, durationMs: 0 };
458
+ for (const r of d.results) {
459
+ const prog = r.progress || r.progressSummary;
460
+ if (!prog) continue;
461
+ sawProgress = true;
462
+ summary.toolCount += prog.toolCount;
463
+ summary.tokens += prog.tokens;
464
+ summary.durationMs = d.mode === "chain" ? summary.durationMs + prog.durationMs : Math.max(summary.durationMs, prog.durationMs);
206
465
  }
466
+ if (sawProgress) totalSummary = summary;
207
467
  }
468
+ const hasParallelInChain = d.chainAgents?.some((a) => a.startsWith("["));
469
+ const totalCount = hasParallelInChain ? d.results.length : (d.totalSteps ?? d.results.length);
470
+ const currentStep = d.currentStepIndex !== undefined ? d.currentStepIndex + 1 : Math.min(totalCount, ok + (hasRunning ? 1 : 0));
471
+ const itemLabel = d.mode === "parallel" ? "agent" : "step";
472
+ const itemTitle = d.mode === "parallel" ? "Agent" : "Step";
473
+ const stepInfo = hasRunning ? `${itemLabel} ${currentStep}/${totalCount}` : `${itemLabel} ${ok}/${totalCount}`;
474
+ const stats = statJoin(theme, [stepInfo, formatProgressStats(theme, totalSummary)]);
475
+ const glyph = hasRunning
476
+ ? theme.fg("accent", spinnerFrame())
477
+ : failed
478
+ ? theme.fg("error", "✗")
479
+ : paused
480
+ ? theme.fg("warning", "■")
481
+ : theme.fg("success", "✓");
482
+ const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
483
+ const c = new Container();
484
+ const width = getTermWidth() - 4;
485
+ c.addChild(new Text(truncLine(`${glyph} ${theme.fg("toolTitle", theme.bold(d.mode))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
208
486
 
209
- ctx.ui.setWidget(WIDGET_KEY, lines);
487
+ const useResultsDirectly = hasParallelInChain || !d.chainAgents?.length;
488
+ const stepsToShow = useResultsDirectly ? d.results.length : d.chainAgents!.length;
489
+ for (let i = 0; i < stepsToShow; i++) {
490
+ const r = d.results[i];
491
+ const agentName = useResultsDirectly ? (r?.agent || `${itemLabel}-${i + 1}`) : (d.chainAgents![i] || r?.agent || `${itemLabel}-${i + 1}`);
492
+ if (!r) {
493
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ◦ ${itemTitle} ${i + 1}: ${agentName} · pending`), width), 0, 0));
494
+ continue;
495
+ }
496
+ const output = getSingleResultOutput(r);
497
+ const progressFromArray = d.progress?.find((p) => p.index === i) || d.progress?.find((p) => p.agent === r.agent && p.status === "running");
498
+ const rProg = r.progress || progressFromArray || r.progressSummary;
499
+ const rRunning = rProg && "status" in rProg && rProg.status === "running";
500
+ const rPending = rProg && "status" in rProg && rProg.status === "pending";
501
+ const stepNumber = r.progress?.index !== undefined ? r.progress.index + 1 : progressFromArray?.index !== undefined ? progressFromArray.index + 1 : i + 1;
502
+ const stepStats = formatProgressStats(theme, rProg);
503
+ const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning);
504
+ const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : "";
505
+ const line = `${glyph} ${itemTitle} ${stepNumber}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
506
+ c.addChild(new Text(truncLine(` ${line}`, width), 0, 0));
507
+ if (rRunning && rProg && "status" in rProg) {
508
+ const activity = compactCurrentActivity(rProg);
509
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
510
+ c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
511
+ } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
512
+ c.addChild(new Text(truncLine(theme.fg(r.exitCode !== 0 ? "error" : "dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
513
+ }
514
+ const outputTarget = extractOutputTarget(r.task);
515
+ if (outputTarget) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${outputTarget}`), width), 0, 0));
516
+ if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
517
+ }
518
+ if (d.artifacts) c.addChild(new Text(truncLine(theme.fg("dim", ` artifacts: ${shortenPath(d.artifacts.dir)}`), width), 0, 0));
519
+ return c;
210
520
  }
211
521
 
212
522
  /**
@@ -230,6 +540,7 @@ export function renderSubagentResult(
230
540
 
231
541
  if (d.mode === "single" && d.results.length === 1) {
232
542
  const r = d.results[0];
543
+ if (!expanded) return renderSingleCompact(d, r, theme);
233
544
  const isRunning = r.progress?.status === "running";
234
545
  const icon = isRunning
235
546
  ? theme.fg("warning", "running")
@@ -322,6 +633,8 @@ export function renderSubagentResult(
322
633
  return c;
323
634
  }
324
635
 
636
+ if (!expanded) return renderMultiCompact(d, theme);
637
+
325
638
  const hasRunning = d.progress?.some((p) => p.status === "running")
326
639
  || d.results.some((r) => r.progress?.status === "running");
327
640
  const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length;
@@ -367,6 +680,7 @@ export function renderSubagentResult(
367
680
  const totalCount = hasParallelInChain ? d.results.length : (d.totalSteps ?? d.results.length);
368
681
  const currentStep = d.currentStepIndex !== undefined ? d.currentStepIndex + 1 : ok + 1;
369
682
  const stepInfo = hasRunning ? ` ${currentStep}/${totalCount}` : ` ${ok}/${totalCount}`;
683
+ const itemTitle = d.mode === "parallel" ? "Agent" : "Step";
370
684
 
371
685
  const chainVis = d.chainAgents?.length && !hasParallelInChain
372
686
  ? d.chainAgents
@@ -418,7 +732,7 @@ export function renderSubagentResult(
418
732
  : (d.chainAgents![i] || r?.agent || `step-${i + 1}`);
419
733
 
420
734
  if (!r) {
421
- c.addChild(new Text(fit(theme.fg("dim", ` Step ${i + 1}: ${agentName}`)), 0, 0));
735
+ c.addChild(new Text(fit(theme.fg("dim", ` ${itemTitle} ${i + 1}: ${agentName}`)), 0, 0));
422
736
  c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
423
737
  c.addChild(new Spacer(1));
424
738
  continue;
@@ -441,8 +755,8 @@ export function renderSubagentResult(
441
755
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
442
756
  const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
443
757
  const stepHeader = rRunning
444
- ? `${statusIcon} Step ${stepNumber}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
445
- : `${statusIcon} Step ${stepNumber}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
758
+ ? `${statusIcon} ${itemTitle} ${stepNumber}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
759
+ : `${statusIcon} ${itemTitle} ${stepNumber}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
446
760
  const toolCallLines = getToolCallLines(r, expanded);
447
761
  c.addChild(new Text(fit(stepHeader), 0, 0));
448
762
 
package/schemas.ts CHANGED
@@ -4,14 +4,31 @@
4
4
 
5
5
  import { Type } from "typebox";
6
6
 
7
- // Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
8
- const SkillOverride = Type.Any({ description: "Skill name(s) to inject (comma-separated), array of strings, or boolean (false disables, true uses default)" });
7
+ const SkillOverride = Type.Unsafe({
8
+ type: ["string", "array", "boolean"],
9
+ items: { type: "string" },
10
+ description: "Skill name(s) to inject (comma-separated), array of strings, or boolean (false disables, true uses default)",
11
+ });
12
+
13
+ const OutputOverride = Type.Unsafe({
14
+ type: ["string", "boolean"],
15
+ description: "Output filename/path (string), or false to disable file output",
16
+ });
17
+
18
+ const ReadsOverride = Type.Unsafe({
19
+ type: ["array", "boolean"],
20
+ items: { type: "string" },
21
+ description: "Files to read before running (array of filenames), or false to disable",
22
+ });
9
23
 
10
24
  export const TaskItem = Type.Object({
11
25
  agent: Type.String(),
12
26
  task: Type.String(),
13
27
  cwd: Type.Optional(Type.String()),
14
28
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
29
+ output: Type.Optional(OutputOverride),
30
+ reads: Type.Optional(ReadsOverride),
31
+ progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking for this task" })),
15
32
  model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
16
33
  skill: Type.Optional(SkillOverride),
17
34
  });
@@ -23,8 +40,8 @@ export const SequentialStepSchema = Type.Object({
23
40
  description: "Task template with variables: {task}=original request, {previous}=prior step's text response, {chain_dir}=shared folder. Required for first step, defaults to '{previous}' for subsequent steps."
24
41
  })),
25
42
  cwd: Type.Optional(Type.String()),
26
- output: Type.Optional(Type.Any({ description: "Output filename to write in {chain_dir} (string), or false to disable file output" })),
27
- reads: Type.Optional(Type.Any({ description: "Files to read from {chain_dir} before running (array of filenames), or false to disable" })),
43
+ output: Type.Optional(OutputOverride),
44
+ reads: Type.Optional(ReadsOverride),
28
45
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
29
46
  skill: Type.Optional(SkillOverride),
30
47
  model: Type.Optional(Type.String({ description: "Override model for this step" })),
@@ -36,8 +53,8 @@ export const ParallelTaskSchema = Type.Object({
36
53
  task: Type.Optional(Type.String({ description: "Task template with {task}, {previous}, {chain_dir} variables. Defaults to {previous}." })),
37
54
  cwd: Type.Optional(Type.String()),
38
55
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
39
- output: Type.Optional(Type.Any({ description: "Output filename to write in {chain_dir} (string), or false to disable file output" })),
40
- reads: Type.Optional(Type.Any({ description: "Files to read from {chain_dir} before running (array of filenames), or false to disable" })),
56
+ output: Type.Optional(OutputOverride),
57
+ reads: Type.Optional(ReadsOverride),
41
58
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
42
59
  skill: Type.Optional(SkillOverride),
43
60
  model: Type.Optional(Type.String({ description: "Override model for this task" })),
@@ -53,9 +70,25 @@ export const ParallelStepSchema = Type.Object({
53
70
  })),
54
71
  });
55
72
 
56
- // Chain item can be either sequential or parallel
57
- // Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
58
- export const ChainItem = Type.Any({ description: "Chain step: either {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
73
+ // Flattened so providers that reject anyOf/oneOf can still accept either sequential or parallel steps.
74
+ export const ChainItem = Type.Object({
75
+ agent: Type.Optional(Type.String({ description: "Sequential step agent name" })),
76
+ task: Type.Optional(Type.String({
77
+ description: "Task template with variables: {task}=original request, {previous}=prior step's text response, {chain_dir}=shared folder. Required for first step, defaults to '{previous}' for subsequent steps."
78
+ })),
79
+ cwd: Type.Optional(Type.String()),
80
+ output: Type.Optional(OutputOverride),
81
+ reads: Type.Optional(ReadsOverride),
82
+ progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
83
+ skill: Type.Optional(SkillOverride),
84
+ model: Type.Optional(Type.String({ description: "Override model for this step" })),
85
+ parallel: Type.Optional(Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" })),
86
+ concurrency: Type.Optional(Type.Number({ description: "Max concurrent tasks (default: 4)" })),
87
+ failFast: Type.Optional(Type.Boolean({ description: "Stop on first failure (default: false)" })),
88
+ worktree: Type.Optional(Type.Boolean({
89
+ description: "Create isolated git worktrees for each parallel task."
90
+ })),
91
+ }, { description: "Chain step: use {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
59
92
 
60
93
  export const ControlOverrides = Type.Object({
61
94
  enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control attention tracking for this run" })),
@@ -70,7 +103,7 @@ export const ControlOverrides = Type.Object({
70
103
 
71
104
  export const SubagentParams = Type.Object({
72
105
  agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode) or target for management get/update/delete" })),
73
- task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
106
+ task: Type.Optional(Type.String({ description: "Task (SINGLE mode, optional for self-contained agents)" })),
74
107
  // Management action (when present, tool operates in management mode)
75
108
  action: Type.Optional(Type.String({
76
109
  description: "Action: management ('list','get','create','update','delete') or control ('status','interrupt'). Omit for execution mode."
@@ -89,10 +122,12 @@ export const SubagentParams = Type.Object({
89
122
  description: "Chain name for get/update/delete management actions"
90
123
  })),
91
124
  // Agent/chain configuration for create/update (nested to avoid conflicts with execution fields)
92
- config: Type.Optional(Type.Any({
93
- description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent."
125
+ config: Type.Optional(Type.Unsafe({
126
+ type: ["object", "string"],
127
+ additionalProperties: true,
128
+ description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
94
129
  })),
95
- tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?}, ...]" })),
130
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, reads?, progress?}, ...]" })),
96
131
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
97
132
  worktree: Type.Optional(Type.Boolean({
98
133
  description: "Create isolated git worktrees for each parallel task. " +
@@ -118,7 +153,10 @@ export const SubagentParams = Type.Object({
118
153
  clarify: Type.Optional(Type.Boolean({ description: "Show TUI to preview/edit before execution (default: true for chains, false for single/parallel). Implies sync mode." })),
119
154
  control: Type.Optional(ControlOverrides),
120
155
  // Solo agent overrides
121
- output: Type.Optional(Type.Any({ description: "Output file for single agent (string), or false to disable. Relative paths resolve against cwd." })),
156
+ output: Type.Optional(Type.Unsafe({
157
+ type: ["string", "boolean"],
158
+ description: "Output file for single agent (string), or false to disable. Relative paths resolve against cwd.",
159
+ })),
122
160
  skill: Type.Optional(SkillOverride),
123
161
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
124
162
  });
package/settings.ts CHANGED
@@ -8,6 +8,7 @@ import type { AgentConfig } from "./agents.ts";
8
8
  import { normalizeSkillInput } from "./skills.ts";
9
9
  import { CHAIN_RUNS_DIR } from "./types.ts";
10
10
  const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
11
+ const INITIAL_PROGRESS_CONTENT = "# Progress\n\n## Status\nIn Progress\n\n## Tasks\n\n## Files Changed\n\n## Notes\n";
11
12
 
12
13
  // =============================================================================
13
14
  // Behavior Resolution Types
@@ -224,6 +225,10 @@ function resolveChainPath(filePath: string, chainDir: string): string {
224
225
  * Build chain instructions from resolved behavior.
225
226
  * These are appended to the task to tell the agent what to read/write.
226
227
  */
228
+ export function writeInitialProgressFile(progressDir: string): void {
229
+ fs.writeFileSync(path.join(progressDir, "progress.md"), INITIAL_PROGRESS_CONTENT);
230
+ }
231
+
227
232
  export function buildChainInstructions(
228
233
  behavior: ResolvedStepBehavior,
229
234
  chainDir: string,