supipowers 0.7.10 → 0.7.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "OMP-native workflow extension inspired by supipowers.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -27,6 +27,7 @@ import { buildWorktreePrompt } from "../git/worktree.js";
27
27
  import { buildBranchFinishPrompt } from "../git/branch-finish.js";
28
28
  import { detectBaseBranch } from "../git/base-branch.js";
29
29
  import type { RunManifest, AgentResult } from "../types.js";
30
+ import { AgentGridWidget, createAgentGridFactory } from "../orchestrator/agent-grid.js";
30
31
 
31
32
  interface ParsedRunArgs {
32
33
  profile?: string;
@@ -175,6 +176,23 @@ export function registerRunCommand(pi: ExtensionAPI): void {
175
176
  const lsp = isLspAvailable(pi.getActiveTools());
176
177
  const ctxMode = detectContextMode(pi.getActiveTools()).available;
177
178
 
179
+ // Mount agent grid widget for live progress visualization
180
+ let widget: AgentGridWidget | undefined;
181
+ if (ctx.hasUI) {
182
+ const widgetReady = new Promise<AgentGridWidget>((resolve) => {
183
+ ctx.ui.setWidget("supi-agents", createAgentGridFactory((w) => {
184
+ widget = w;
185
+ for (const task of plan.tasks) {
186
+ w.addTask(task.id, task.name);
187
+ }
188
+ resolve(w);
189
+ }));
190
+ });
191
+ // Wait for TUI to instantiate the widget before dispatching agents
192
+ widget = await widgetReady;
193
+ }
194
+
195
+ try {
178
196
  for (const batch of manifest.batches) {
179
197
  if (batch.status === "completed") continue;
180
198
 
@@ -200,6 +218,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
200
218
  config,
201
219
  lspAvailable: lsp,
202
220
  contextModeAvailable: ctxMode,
221
+ widget,
203
222
  });
204
223
  });
205
224
 
@@ -236,6 +255,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
236
255
  config,
237
256
  lspAvailable: lsp,
238
257
  contextModeAvailable: ctxMode,
258
+ widget,
239
259
  previousOutput: failed.output,
240
260
  failureReason: failed.output,
241
261
  });
@@ -267,6 +287,7 @@ export function registerRunCommand(pi: ExtensionAPI): void {
267
287
  manifest.completedAt = new Date().toISOString();
268
288
  updateRun(ctx.cwd, manifest);
269
289
 
290
+
270
291
  const durationSec = Math.round(runSummary.totalDuration / 1000);
271
292
  notifySummary(
272
293
  ctx,
@@ -292,6 +313,13 @@ export function registerRunCommand(pi: ExtensionAPI): void {
292
313
  );
293
314
  notifyInfo(ctx, "Run succeeded", "Follow branch finish instructions to integrate your work");
294
315
  }
316
+ } finally {
317
+ // Ensure widget interval timer is cleaned up even on exception
318
+ widget?.dispose();
319
+ if (ctx.hasUI) {
320
+ ctx.ui.setWidget("supi-agents", undefined);
321
+ }
322
+ }
295
323
  },
296
324
  });
297
325
  }
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Agent Grid Widget — TUI component for live sub-agent progress visualization.
3
+ *
4
+ * Renders task cards in a flexbox-like grid that reflows based on terminal width.
5
+ * Each card shows: status icon, task name, current thinking, tool activity log,
6
+ * files changed count, and elapsed time.
7
+ *
8
+ * Cards collapse when done/blocked to make room for active cards.
9
+ */
10
+
11
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ const MIN_CARD_WIDTH = 38;
13
+ const MAX_ACTIVITY_LOG = 4;
14
+ const ANIMATION_INTERVAL_MS = 200;
15
+
16
+ // ── Types ──────────────────────────────────────────────────────────
17
+
18
+ export type TaskStatus =
19
+ | "pending"
20
+ | "running"
21
+ | "reviewing"
22
+ | "done"
23
+ | "done_with_concerns"
24
+ | "blocked";
25
+
26
+ export interface TaskCardState {
27
+ taskId: number;
28
+ name: string;
29
+ status: TaskStatus;
30
+ currentThinking: string;
31
+ activityLog: string[];
32
+ filesChanged: number;
33
+ toolCount: number;
34
+ startedAt: number;
35
+ completedAt?: number;
36
+ errorReason?: string;
37
+ concerns?: string;
38
+ }
39
+
40
+ /** Minimal TUI interface — matches what setWidget factory receives */
41
+ interface TUI {
42
+ requestRender(): void;
43
+ }
44
+
45
+ /** Minimal Theme interface — matches OMP's Theme class */
46
+ interface Theme {
47
+ fg(color: string, text: string): string;
48
+ symbol(key: string): string;
49
+ }
50
+
51
+ /** Component interface as defined by @oh-my-pi/pi-tui */
52
+ interface Component {
53
+ render(width: number): string[];
54
+ invalidate(): void;
55
+ dispose?(): void;
56
+ }
57
+
58
+ // ── Status Config ──────────────────────────────────────────────────
59
+
60
+ interface StatusConfig {
61
+ color: string;
62
+ icon: string;
63
+ }
64
+
65
+ const STATUS_CONFIG: Record<TaskStatus, StatusConfig> = {
66
+ pending: { color: "dim", icon: "○" },
67
+ running: { color: "accent", icon: "●" },
68
+ reviewing: { color: "warning", icon: "◎" },
69
+ done: { color: "success", icon: "✓" },
70
+ done_with_concerns: { color: "warning", icon: "⚠" },
71
+ blocked: { color: "error", icon: "✗" },
72
+ };
73
+
74
+ // ── AgentGridWidget ────────────────────────────────────────────────
75
+
76
+ export class AgentGridWidget implements Component {
77
+ #tasks = new Map<number, TaskCardState>();
78
+ #tui: TUI;
79
+ #theme: Theme;
80
+ #spinnerFrame = 0;
81
+ #intervalId: ReturnType<typeof setInterval> | null = null;
82
+ #cachedLines: string[] | undefined;
83
+ #cachedWidth: number | undefined;
84
+
85
+ constructor(tui: TUI, theme: Theme) {
86
+ this.#tui = tui;
87
+ this.#theme = theme;
88
+
89
+ // Animate spinner + elapsed time
90
+ this.#intervalId = setInterval(() => {
91
+ this.#spinnerFrame = (this.#spinnerFrame + 1) % SPINNER_FRAMES.length;
92
+ // Only re-render if we have active tasks
93
+ const hasActive = [...this.#tasks.values()].some(
94
+ (t) => t.status === "running" || t.status === "reviewing",
95
+ );
96
+ if (hasActive) {
97
+ this.invalidate();
98
+ this.#tui.requestRender();
99
+ }
100
+ }, ANIMATION_INTERVAL_MS);
101
+ }
102
+
103
+ // ── Public API (called by dispatcher) ──────────────────────────
104
+
105
+ /** Initialize a task card */
106
+ addTask(taskId: number, name: string): void {
107
+ this.#tasks.set(taskId, {
108
+ taskId,
109
+ name,
110
+ status: "pending",
111
+ currentThinking: "",
112
+ activityLog: [],
113
+ filesChanged: 0,
114
+ toolCount: 0,
115
+ startedAt: Date.now(),
116
+ });
117
+ this.#invalidateAndRender();
118
+ }
119
+
120
+ /** Update task status */
121
+ setStatus(taskId: number, status: TaskStatus, reason?: string): void {
122
+ const task = this.#tasks.get(taskId);
123
+ if (!task) return;
124
+ task.status = status;
125
+ if (status === "blocked") task.errorReason = reason;
126
+ if (status === "done_with_concerns") task.concerns = reason;
127
+ if (status === "done" || status === "done_with_concerns" || status === "blocked") {
128
+ task.completedAt = Date.now();
129
+ }
130
+ this.#invalidateAndRender();
131
+ }
132
+
133
+ /** Update thinking preview */
134
+ setThinking(taskId: number, text: string): void {
135
+ const task = this.#tasks.get(taskId);
136
+ if (!task) return;
137
+ task.currentThinking = text;
138
+ this.#invalidateAndRender();
139
+ }
140
+
141
+ /** Add a tool call to the activity log */
142
+ addActivity(taskId: number, description: string): void {
143
+ const task = this.#tasks.get(taskId);
144
+ if (!task) return;
145
+ task.toolCount++;
146
+ task.activityLog.push(description);
147
+ if (task.activityLog.length > MAX_ACTIVITY_LOG) {
148
+ task.activityLog.shift();
149
+ }
150
+ this.#invalidateAndRender();
151
+ }
152
+
153
+ /** Increment files changed count */
154
+ addFileChanged(taskId: number): void {
155
+ const task = this.#tasks.get(taskId);
156
+ if (!task) return;
157
+ task.filesChanged++;
158
+ this.#invalidateAndRender();
159
+ }
160
+
161
+ // ── Component Interface ────────────────────────────────────────
162
+
163
+ render(width: number): string[] {
164
+ if (this.#cachedLines && this.#cachedWidth === width) return this.#cachedLines;
165
+ if (this.#tasks.size === 0) return [];
166
+
167
+ const tasks = [...this.#tasks.values()];
168
+ const cardWidth = this.#computeCardWidth(width);
169
+ const cardsPerRow = Math.max(1, Math.floor(width / cardWidth));
170
+
171
+ const lines: string[] = [];
172
+
173
+ // Render in rows
174
+ for (let i = 0; i < tasks.length; i += cardsPerRow) {
175
+ const rowTasks = tasks.slice(i, i + cardsPerRow);
176
+ const rowCards = rowTasks.map((t) => this.#renderCard(t, cardWidth));
177
+ const merged = this.#mergeCardRows(rowCards, cardWidth, width);
178
+ lines.push(...merged);
179
+ }
180
+
181
+ this.#cachedLines = lines;
182
+ this.#cachedWidth = width;
183
+ return lines;
184
+ }
185
+
186
+ invalidate(): void {
187
+ this.#cachedLines = undefined;
188
+ this.#cachedWidth = undefined;
189
+ }
190
+
191
+ dispose(): void {
192
+ if (this.#intervalId) {
193
+ clearInterval(this.#intervalId);
194
+ this.#intervalId = null;
195
+ }
196
+ }
197
+
198
+ // ── Private Rendering ──────────────────────────────────────────
199
+
200
+ /** Box drawing chars shorthand */
201
+ get #box() {
202
+ return {
203
+ tl: this.#theme.symbol("boxRound.topLeft"),
204
+ tr: this.#theme.symbol("boxRound.topRight"),
205
+ bl: this.#theme.symbol("boxRound.bottomLeft"),
206
+ br: this.#theme.symbol("boxRound.bottomRight"),
207
+ h: this.#theme.symbol("boxRound.horizontal"),
208
+ v: this.#theme.symbol("boxRound.vertical"),
209
+ };
210
+ }
211
+
212
+ #invalidateAndRender(): void {
213
+ this.invalidate();
214
+ this.#tui.requestRender();
215
+ }
216
+
217
+ #computeCardWidth(termWidth: number): number {
218
+ if (termWidth < MIN_CARD_WIDTH) return termWidth; // terminal too narrow, use full width
219
+ const maxWidth = Math.floor(termWidth / 2);
220
+ const cardsPerRow = Math.max(1, Math.floor(termWidth / MIN_CARD_WIDTH));
221
+ const computed = Math.floor(termWidth / cardsPerRow);
222
+ return Math.max(MIN_CARD_WIDTH, Math.min(computed, maxWidth));
223
+ }
224
+
225
+ #renderCard(task: TaskCardState, width: number): string[] {
226
+ const isCollapsed =
227
+ task.status === "done" ||
228
+ task.status === "done_with_concerns" ||
229
+ task.status === "blocked" ||
230
+ task.status === "pending";
231
+
232
+ if (isCollapsed) {
233
+ return this.#renderCollapsedCard(task, width);
234
+ }
235
+ return this.#renderActiveCard(task, width);
236
+ }
237
+
238
+ #renderCollapsedCard(task: TaskCardState, width: number): string[] {
239
+ const { color, icon } = STATUS_CONFIG[task.status];
240
+ const box = this.#box;
241
+ const inner = width - 4; // 2 border + 2 padding
242
+
243
+ if (task.status === "pending") {
244
+ const title = this.#truncate(`${icon} Task ${task.taskId}: ${task.name}`, inner);
245
+ return [
246
+ this.#theme.fg(color, `${box.tl}${box.h} ${title} ${this.#pad(box.h, inner - title.length - 1)}${box.tr}`),
247
+ this.#theme.fg(color, `${box.bl}${this.#pad(box.h, width - 2)}${box.br}`),
248
+ ];
249
+ }
250
+
251
+ // Done / blocked / concerns
252
+ let suffix = "";
253
+ if (task.status === "done" || task.status === "done_with_concerns") {
254
+ const elapsed = this.#formatElapsed(task);
255
+ suffix = `${task.filesChanged} files, ${elapsed}`;
256
+ } else if (task.status === "blocked") {
257
+ suffix = "BLOCKED";
258
+ }
259
+
260
+ const title = this.#truncate(`${icon} Task ${task.taskId}: ${task.name}`, inner - suffix.length - 4);
261
+ const headerContent = `${title} ── ${suffix}`;
262
+ const headerPad = Math.max(0, inner - this.#visibleLength(headerContent) - 1);
263
+
264
+ const lines = [
265
+ this.#theme.fg(color, `${box.tl}${box.h} ${headerContent}${this.#pad(box.h, headerPad)} ${box.tr}`),
266
+ ];
267
+
268
+ // Show error reason for blocked, concerns for done_with_concerns
269
+ const detail = task.status === "blocked" ? task.errorReason : task.concerns;
270
+ if (detail) {
271
+ const detailText = this.#truncate(` ${detail}`, inner);
272
+ lines.push(
273
+ this.#theme.fg(color, box.v) +
274
+ ` ${detailText}${" ".repeat(Math.max(0, inner - this.#visibleLength(detailText)))} ` +
275
+ this.#theme.fg(color, box.v),
276
+ );
277
+ }
278
+
279
+ lines.push(
280
+ this.#theme.fg(color, `${box.bl}${this.#pad(box.h, width - 2)}${box.br}`),
281
+ );
282
+ return lines;
283
+ }
284
+
285
+ #renderActiveCard(task: TaskCardState, width: number): string[] {
286
+ const { color, icon } = STATUS_CONFIG[task.status];
287
+ const box = this.#box;
288
+ const inner = width - 4; // 2 border + 2 padding
289
+ const spinner = SPINNER_FRAMES[this.#spinnerFrame];
290
+
291
+ const lines: string[] = [];
292
+
293
+ // ── Header
294
+ const title = this.#truncate(`${icon} Task ${task.taskId}: ${task.name}`, inner);
295
+ lines.push(
296
+ this.#theme.fg(color, `${box.tl}${box.h} ${title} ${this.#pad(box.h, inner - this.#visibleLength(title) - 1)}${box.tr}`),
297
+ );
298
+
299
+ // ── Sticky thinking line
300
+ const thinkingText = task.currentThinking
301
+ ? this.#truncate(`${spinner} ${task.currentThinking}`, inner)
302
+ : `${spinner} Working...`;
303
+ lines.push(this.#line(
304
+ this.#theme.fg("dim", thinkingText),
305
+ inner,
306
+ color,
307
+ ));
308
+
309
+ // ── Separator
310
+ const sep = this.#pad("─", inner);
311
+ lines.push(this.#line(this.#theme.fg("dim", sep), inner, color));
312
+
313
+ // ── Activity log (pad to MAX_ACTIVITY_LOG lines)
314
+ const logLines = task.activityLog.slice(-MAX_ACTIVITY_LOG);
315
+ for (let i = 0; i < MAX_ACTIVITY_LOG; i++) {
316
+ const entry = logLines[i];
317
+ if (entry) {
318
+ const text = this.#truncate(` ${entry}`, inner);
319
+ // Tool entries start with action verbs (Reading, Editing, Running, etc.)
320
+ // Thinking entries are plain text from the agent
321
+ const isToolAction = /^ (?:Reading|Editing|Writing|Running|Searching|Finding|Spawning|Glob|Grep|Bash|Edit|Read|Write)/.test(entry);
322
+ const styled = isToolAction ? text : this.#theme.fg("dim", text);
323
+ lines.push(this.#line(styled, inner, color));
324
+ } else {
325
+ lines.push(this.#line("", inner, color));
326
+ }
327
+ }
328
+
329
+ // ── Empty line
330
+ lines.push(this.#line("", inner, color));
331
+
332
+ // ── Footer
333
+ const filesLabel = `${task.filesChanged} file${task.filesChanged !== 1 ? "s" : ""}`;
334
+ const elapsed = `⏱ ${this.#formatElapsed(task)}`;
335
+ const gap = Math.max(1, inner - this.#visibleLength(filesLabel) - this.#visibleLength(elapsed));
336
+ const footerContent = `${filesLabel}${" ".repeat(gap)}${elapsed}`;
337
+ lines.push(this.#line(this.#theme.fg("dim", footerContent), inner, color));
338
+
339
+ // ── Bottom border
340
+ lines.push(
341
+ this.#theme.fg(color, `${box.bl}${this.#pad(box.h, width - 2)}${box.br}`),
342
+ );
343
+
344
+ return lines;
345
+ }
346
+
347
+ /** Render a content line with colored side borders */
348
+ #line(content: string, inner: number, borderColor: string): string {
349
+ const box = this.#box;
350
+ const contentLen = this.#visibleLength(content);
351
+ const pad = Math.max(0, inner - contentLen);
352
+ return (
353
+ this.#theme.fg(borderColor, box.v) +
354
+ ` ${content}${" ".repeat(pad)} ` +
355
+ this.#theme.fg(borderColor, box.v)
356
+ );
357
+ }
358
+
359
+ /** Merge multiple card columns into rows of lines */
360
+ #mergeCardRows(cards: string[][], cardWidth: number, termWidth: number): string[] {
361
+ const maxHeight = Math.max(...cards.map((c) => c.length));
362
+ const gap = 1; // space between cards
363
+ const merged: string[] = [];
364
+
365
+ for (let row = 0; row < maxHeight; row++) {
366
+ let line = "";
367
+ for (let col = 0; col < cards.length; col++) {
368
+ if (col > 0) line += " ".repeat(gap);
369
+ const cardLine = cards[col][row] ?? " ".repeat(cardWidth);
370
+ line += cardLine;
371
+ }
372
+ merged.push(line);
373
+ }
374
+ return merged;
375
+ }
376
+
377
+ // ── Utilities ──────────────────────────────────────────────────
378
+
379
+ #truncate(text: string, maxLen: number): string {
380
+ if (this.#visibleLength(text) <= maxLen) return text;
381
+ // Walk through the string, counting only visible chars, preserving ANSI sequences
382
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
383
+ let result = "";
384
+ let visible = 0;
385
+ let lastIndex = 0;
386
+ let match: RegExpExecArray | null;
387
+ while ((match = ansiRegex.exec(text)) !== null) {
388
+ // Add visible chars before this ANSI sequence
389
+ const before = text.slice(lastIndex, match.index);
390
+ const remaining = maxLen - 1 - visible;
391
+ if (before.length > remaining) {
392
+ result += before.slice(0, remaining) + "…";
393
+ return result + "\x1b[0m"; // reset styling
394
+ }
395
+ result += before;
396
+ visible += before.length;
397
+ result += match[0]; // preserve ANSI sequence
398
+ lastIndex = match.index + match[0].length;
399
+ }
400
+ // Handle remaining text after last ANSI sequence
401
+ const tail = text.slice(lastIndex);
402
+ const remaining = maxLen - 1 - visible;
403
+ if (tail.length > remaining) {
404
+ result += tail.slice(0, remaining) + "…";
405
+ return result + "\x1b[0m";
406
+ }
407
+ result += tail;
408
+ return result;
409
+ }
410
+
411
+ #visibleLength(text: string): number {
412
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
413
+ }
414
+
415
+ #pad(char: string, count: number): string {
416
+ return char.repeat(Math.max(0, count));
417
+ }
418
+
419
+ #formatElapsed(task: TaskCardState): string {
420
+ const end = task.completedAt ?? Date.now();
421
+ const sec = Math.floor((end - task.startedAt) / 1000);
422
+ if (sec < 60) return `${sec}s`;
423
+ const min = Math.floor(sec / 60);
424
+ return `${min}m ${sec % 60}s`;
425
+ }
426
+ }
427
+
428
+ // ── Factory ────────────────────────────────────────────────────────
429
+
430
+ /** Create the widget factory for ctx.ui.setWidget */
431
+ export function createAgentGridFactory(
432
+ onCreated: (widget: AgentGridWidget) => void,
433
+ ): (tui: TUI, theme: Theme) => AgentGridWidget {
434
+ return (tui: TUI, theme: Theme) => {
435
+ const widget = new AgentGridWidget(tui, theme);
436
+ onCreated(widget);
437
+ return widget;
438
+ };
439
+ }
@@ -18,6 +18,7 @@ import {
18
18
  notifyError,
19
19
  notifyInfo,
20
20
  } from "../notifications/renderer.js";
21
+ import type { AgentGridWidget } from "./agent-grid.js";
21
22
 
22
23
  export interface DispatchOptions {
23
24
  pi: ExtensionAPI;
@@ -30,18 +31,22 @@ export interface DispatchOptions {
30
31
  config: SupipowersConfig;
31
32
  lspAvailable: boolean;
32
33
  contextModeAvailable: boolean;
34
+ widget?: AgentGridWidget;
33
35
  }
34
36
 
35
37
  export async function dispatchAgent(
36
38
  options: DispatchOptions,
37
39
  ): Promise<AgentResult> {
38
- const { pi, ctx, task, planContext, config, lspAvailable, contextModeAvailable } = options;
40
+ const { pi, ctx, task, planContext, config, lspAvailable, contextModeAvailable, widget } = options;
39
41
  const startTime = Date.now();
40
42
 
41
43
  const prompt = buildTaskPrompt(task, planContext, config, lspAvailable, contextModeAvailable);
42
44
 
45
+ // Initialize widget card if available
46
+ widget?.setStatus(task.id, "running");
47
+
43
48
  try {
44
- const result = await executeSubAgent(pi, prompt, task, config);
49
+ const result = await executeSubAgent(pi, prompt, task, config, ctx, widget);
45
50
 
46
51
  const agentResult: AgentResult = {
47
52
  taskId: task.id,
@@ -52,28 +57,31 @@ export async function dispatchAgent(
52
57
  duration: Date.now() - startTime,
53
58
  };
54
59
 
60
+ // Update widget with final status
55
61
  switch (agentResult.status) {
56
62
  case "done":
63
+ widget?.setStatus(task.id, "done");
57
64
  notifySuccess(ctx, `Task ${task.id} completed`, task.name);
58
65
  break;
59
66
  case "done_with_concerns":
60
- notifyWarning(
61
- ctx,
62
- `Task ${task.id} done with concerns`,
63
- agentResult.concerns,
64
- );
67
+ widget?.setStatus(task.id, "done_with_concerns", agentResult.concerns);
68
+ notifyWarning(ctx, `Task ${task.id} done with concerns`, agentResult.concerns);
65
69
  break;
66
70
  case "blocked":
71
+ widget?.setStatus(task.id, "blocked", agentResult.output);
67
72
  notifyError(ctx, `Task ${task.id} blocked`, agentResult.output);
68
73
  break;
69
74
  }
70
75
 
71
76
  return agentResult;
72
77
  } catch (error) {
78
+ const errorMsg = `Agent error: ${error instanceof Error ? error.message : String(error)}`;
79
+ widget?.setStatus(task.id, "blocked", errorMsg);
80
+
73
81
  const agentResult: AgentResult = {
74
82
  taskId: task.id,
75
83
  status: "blocked",
76
- output: `Agent error: ${error instanceof Error ? error.message : String(error)}`,
84
+ output: errorMsg,
77
85
  filesChanged: [],
78
86
  duration: Date.now() - startTime,
79
87
  };
@@ -89,11 +97,41 @@ interface SubAgentResult {
89
97
  filesChanged: string[];
90
98
  }
91
99
 
100
+ type NotifyCtx = { ui: { notify(msg: string, type?: "info" | "warning" | "error"): void } };
101
+
102
+ /** Shorten a file path for display */
103
+ function shortenPath(filePath: string): string {
104
+ const parts = filePath.split("/");
105
+ return parts.length > 3
106
+ ? `.../${parts.slice(-3).join("/")}`
107
+ : filePath;
108
+ }
109
+
110
+ /** Format a tool call for display */
111
+ function formatToolAction(toolName: string, args?: Record<string, unknown>): string {
112
+ const path = args?.file_path ? shortenPath(String(args.file_path)) : "";
113
+ switch (toolName) {
114
+ case "Read": return `Reading ${path}`;
115
+ case "Edit": return `Editing ${path}`;
116
+ case "Write": return `Writing ${path}`;
117
+ case "Bash": {
118
+ const cmd = String(args?.command ?? "").slice(0, 60);
119
+ return `Running: ${cmd}${String(args?.command ?? "").length > 60 ? "..." : ""}`;
120
+ }
121
+ case "Grep": return `Searching for ${args?.pattern ?? "pattern"}`;
122
+ case "Glob": return `Finding files ${args?.pattern ?? ""}`;
123
+ case "Agent": return "Spawning sub-agent";
124
+ default: return `${toolName}${path ? ` ${path}` : ""}`;
125
+ }
126
+ }
127
+
92
128
  async function executeSubAgent(
93
129
  pi: ExtensionAPI,
94
130
  prompt: string,
95
131
  task: PlanTask,
96
132
  config: SupipowersConfig,
133
+ ctx?: NotifyCtx,
134
+ widget?: AgentGridWidget,
97
135
  ): Promise<SubAgentResult> {
98
136
  const { createAgentSession } = pi.pi;
99
137
 
@@ -102,22 +140,49 @@ async function executeSubAgent(
102
140
  hasUI: false,
103
141
  taskDepth: 1,
104
142
  parentTaskPrefix: `task-${task.id}`,
143
+ // Prevent sub-agent from re-loading supipowers (causes recursive init error)
144
+ disableExtensionDiscovery: true,
145
+ skills: [],
146
+ promptTemplates: [],
147
+ slashCommands: [],
105
148
  });
106
149
 
107
- // Track files changed by monitoring tool calls
150
+ // Track files changed and emit live progress
108
151
  const filesChanged = new Set<string>();
109
152
  const FILE_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
110
153
  const pendingToolArgs = new Map<string, Record<string, unknown>>();
154
+ const tag = `Task ${task.id}`;
155
+ let lastThinkingPreview = "";
111
156
  const unsubscribe = session.subscribe((event) => {
112
- if (event.type === "tool_execution_start" && FILE_TOOLS.has(event.toolName)) {
113
- if (event.args?.file_path) {
114
- pendingToolArgs.set(event.toolCallId, event.args as Record<string, unknown>);
157
+ if (event.type === "message_update") {
158
+ const msg = event.message as { content?: unknown } | undefined;
159
+ const content = extractTextContent(msg?.content);
160
+ const preview = content.split("\n").filter(Boolean).pop()?.slice(0, 80) ?? "";
161
+ if (preview && preview !== lastThinkingPreview) {
162
+ lastThinkingPreview = preview;
163
+ if (widget) {
164
+ widget.setThinking(task.id, preview);
165
+ } else if (ctx) {
166
+ ctx.ui.notify(`${tag}: thinking — ${preview}`, "info");
167
+ }
168
+ }
169
+ }
170
+ if (event.type === "tool_execution_start") {
171
+ const args = event.args as Record<string, unknown> | undefined;
172
+ if (FILE_TOOLS.has(event.toolName) && args?.file_path) {
173
+ pendingToolArgs.set(event.toolCallId, args);
174
+ }
175
+ if (widget) {
176
+ widget.addActivity(task.id, formatToolAction(event.toolName, args));
177
+ } else if (ctx) {
178
+ ctx.ui.notify(`${tag}: ${formatToolAction(event.toolName, args)}`, "info");
115
179
  }
116
180
  }
117
- if (event.type === "tool_execution_end" && !event.isError) {
181
+ if (event.type === "tool_execution_end") {
118
182
  const args = pendingToolArgs.get(event.toolCallId);
119
- if (args?.file_path) {
183
+ if (args?.file_path && !event.isError) {
120
184
  filesChanged.add(String(args.file_path));
185
+ widget?.addFileChanged(task.id);
121
186
  }
122
187
  pendingToolArgs.delete(event.toolCallId);
123
188
  }
@@ -132,7 +197,8 @@ async function executeSubAgent(
132
197
  .reverse()
133
198
  .find((m) => m.role === "assistant");
134
199
 
135
- const output = extractTextContent(lastAssistant?.content);
200
+ const lastMsg = lastAssistant as { content?: unknown } | undefined;
201
+ const output = extractTextContent(lastMsg?.content);
136
202
  const status = parseAgentStatus(output);
137
203
 
138
204
  return {
@@ -162,15 +228,12 @@ function extractTextContent(content: unknown): string {
162
228
  }
163
229
 
164
230
  function parseAgentStatus(output: string): AgentStatus {
165
- const upper = output.toUpperCase();
166
- if (upper.includes("BLOCKED") || upper.includes("NEEDS_CONTEXT")) {
167
- return "blocked";
168
- }
169
- if (upper.includes("DONE_WITH_CONCERNS")) {
170
- return "done_with_concerns";
171
- }
172
- // "DONE" appears in both "DONE" and "DONE_WITH_CONCERNS", so check after
173
- if (upper.includes("**STATUS:** DONE") || upper.includes("STATUS: DONE")) {
231
+ // Look for structured "**Status:** X" or "Status: X" patterns to avoid false positives
232
+ const statusMatch = output.match(/\*?\*?status\*?\*?:\s*(BLOCKED|NEEDS_CONTEXT|DONE_WITH_CONCERNS|DONE)/i);
233
+ if (statusMatch) {
234
+ const val = statusMatch[1].toUpperCase();
235
+ if (val === "BLOCKED" || val === "NEEDS_CONTEXT") return "blocked";
236
+ if (val === "DONE_WITH_CONCERNS") return "done_with_concerns";
174
237
  return "done";
175
238
  }
176
239
  // Default: if agent completed without explicit status, treat as done
@@ -212,6 +275,7 @@ export async function dispatchAgentWithReview(
212
275
  }
213
276
 
214
277
  // Step 2: Spec compliance review
278
+ options.widget?.setStatus(task.id, "reviewing");
215
279
  for (let attempt = 0; attempt <= maxReviewRetries; attempt++) {
216
280
  const specReview = await dispatchSpecReview(
217
281
  pi,
@@ -255,6 +319,7 @@ export async function dispatchAgentWithReview(
255
319
  }
256
320
 
257
321
  // Step 3: Code quality review
322
+ options.widget?.setStatus(task.id, "reviewing");
258
323
  for (let attempt = 0; attempt <= maxReviewRetries; attempt++) {
259
324
  const qualityReview = await dispatchQualityReview(
260
325
  pi,
@@ -357,7 +422,7 @@ async function dispatchQualityReview(
357
422
  export async function dispatchFixAgent(
358
423
  options: DispatchOptions & { previousOutput: string; failureReason: string },
359
424
  ): Promise<AgentResult> {
360
- const { pi, ctx, task, config, lspAvailable, contextModeAvailable, previousOutput, failureReason } =
425
+ const { pi, ctx, task, config, lspAvailable, contextModeAvailable, previousOutput, failureReason, widget } =
361
426
  options;
362
427
  const startTime = Date.now();
363
428
 
@@ -369,8 +434,10 @@ export async function dispatchFixAgent(
369
434
  contextModeAvailable,
370
435
  );
371
436
 
437
+ widget?.setStatus(task.id, "running");
438
+
372
439
  try {
373
- const result = await executeSubAgent(pi, prompt, task, config);
440
+ const result = await executeSubAgent(pi, prompt, task, config, ctx, widget);
374
441
  return {
375
442
  taskId: task.id,
376
443
  status: result.status,