@pellux/goodvibes-tui 0.19.85 → 0.19.87

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": "@pellux/goodvibes-tui",
3
- "version": "0.19.85",
3
+ "version": "0.19.87",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -97,7 +97,7 @@
97
97
  "@anthropic-ai/vertex-sdk": "^0.16.0",
98
98
  "@ast-grep/napi": "^0.42.0",
99
99
  "@aws/bedrock-token-generator": "^1.1.0",
100
- "@pellux/goodvibes-sdk": "0.33.20",
100
+ "@pellux/goodvibes-sdk": "0.33.22",
101
101
  "bash-language-server": "^5.6.0",
102
102
  "fuse.js": "^7.1.0",
103
103
  "graphql": "^16.13.2",
package/src/main.ts CHANGED
@@ -16,7 +16,6 @@ import { PermissionPromptUI } from './permissions/prompt.ts';
16
16
  import { CommandRegistry } from './input/command-registry.ts';
17
17
  import type { CommandContext } from './input/command-registry.ts';
18
18
  import { renderProcessIndicator } from './renderer/process-indicator.ts';
19
- import { WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
20
19
  import { registerBuiltinCommands } from './input/commands.ts';
21
20
  import { ScheduleManager } from '@pellux/goodvibes-sdk/platform/tools';
22
21
  import { InputHistory } from './input/input-history.ts';
@@ -54,6 +53,7 @@ import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './au
54
53
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
55
54
  import { ProjectPlanningCoordinator } from './planning/project-planning-coordinator.ts';
56
55
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
56
+ import { summarizeRunningAgents } from './renderer/process-summary.ts';
57
57
 
58
58
  const ALT_SCREEN_ENTER = '\x1b[?1049h';
59
59
  const ALT_SCREEN_EXIT = '\x1b[?1049l';
@@ -483,17 +483,8 @@ async function main() {
483
483
  (a) => a.status === 'running' || a.status === 'pending',
484
484
  );
485
485
  const runtimeAgents = agentSnapshot.active;
486
- const runningAgentIds = new Set<string>();
487
- let runningAgentProgress: string | undefined;
488
- for (const agent of managerAgents) {
489
- runningAgentIds.add(agent.id);
490
- if (!runningAgentProgress && agent.progress) runningAgentProgress = agent.progress;
491
- }
492
- for (const agent of runtimeAgents) {
493
- runningAgentIds.add(agent.id);
494
- if (!runningAgentProgress && agent.latestProgress) runningAgentProgress = agent.latestProgress;
495
- }
496
- const runningAgentCount = runningAgentIds.size;
486
+ const runningAgentSummary = summarizeRunningAgents(managerAgents, runtimeAgents, ctx.services.wrfcController.listChains());
487
+ const runningAgentCount = runningAgentSummary.count;
497
488
  const runningProcessCount = processManager.list().filter((p) => !p.status.startsWith('done')).length;
498
489
  const cw = getPromptContentWidth();
499
490
  const promptInfo = input.getWrappedPromptInfo(cw);
@@ -540,7 +531,7 @@ async function main() {
540
531
  runningAgentCount,
541
532
  runningProcessCount,
542
533
  indicatorFocused: input.indicatorFocused,
543
- runningAgentProgress,
534
+ runningAgentProgress: runningAgentSummary.progress,
544
535
  composerMode: composerState.modeLabel,
545
536
  composerStatus: composerState.statusLabel,
546
537
  composerFlags: composerState.flags,
@@ -14,6 +14,8 @@ export interface ProcessEntry {
14
14
  id: string;
15
15
  /** Display label (agent task or exec command) */
16
16
  label: string;
17
+ /** Tree prefix for child processes, e.g. "└─ " under a WRFC owner. */
18
+ treePrefix?: string;
17
19
  /** Process type */
18
20
  type: 'agent' | 'exec';
19
21
  /** Current status string */
@@ -31,12 +33,32 @@ const MAX_LABEL_LENGTH = 80;
31
33
  /** Border and margin width subtracted from terminal width to get modal content width. */
32
34
  const MODAL_BORDER_WIDTH = 8;
33
35
 
36
+ const WRFC_ROLE_ORDER: Record<string, number> = {
37
+ owner: 0,
38
+ engineer: 1,
39
+ reviewer: 2,
40
+ fixer: 3,
41
+ verifier: 4,
42
+ };
43
+
34
44
  export interface ProcessModalDeps {
35
45
  readonly agentManager: Pick<AgentManager, 'list' | 'getStatus' | 'cancel'>;
36
46
  readonly processManager: Pick<ProcessManager, 'list' | 'getStatus' | 'stop'>;
37
- readonly wrfcController: Pick<WrfcController, 'getChain'>;
47
+ readonly wrfcController: Pick<WrfcController, 'getChain'> & Partial<Pick<WrfcController, 'listChains'>>;
38
48
  }
39
49
 
50
+ type WrfcChainLike = {
51
+ readonly id: string;
52
+ readonly state: string;
53
+ readonly task: string;
54
+ readonly ownerAgentId: string;
55
+ readonly engineerAgentId?: string;
56
+ readonly reviewerAgentId?: string;
57
+ readonly fixerAgentId?: string;
58
+ readonly allAgentIds?: readonly string[];
59
+ readonly constraints?: readonly unknown[];
60
+ };
61
+
40
62
  /** Build a display label for an agent based on its task and template. */
41
63
  function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
42
64
  const task = rec.task;
@@ -44,6 +66,21 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
44
66
  // Look up the original task from the WRFC chain if available
45
67
  const originalTask = getChainTask(rec.wrfcId, deps);
46
68
 
69
+ if (rec.wrfcRole === 'owner') {
70
+ const desc = truncateFirst(originalTask ?? task, MAX_LABEL_LENGTH - 13);
71
+ return `[WRFC owner] ${desc}`;
72
+ }
73
+
74
+ if (rec.wrfcRole === 'engineer') {
75
+ const desc = truncateFirst(originalTask ?? task, MAX_LABEL_LENGTH - 11);
76
+ return `[Engineer] ${desc}`;
77
+ }
78
+
79
+ if (rec.wrfcRole === 'verifier') {
80
+ const desc = truncateFirst(originalTask ?? task, MAX_LABEL_LENGTH - 13);
81
+ return `[Verifier] ${desc}`;
82
+ }
83
+
47
84
  // WRFC Review agent
48
85
  if (task.startsWith('WRFC Review Request')) {
49
86
  const thresholdMatch = task.match(/threshold is (\d+(?:\.\d+)?)/);
@@ -61,8 +98,8 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
61
98
  const attempt = attemptMatch ? attemptMatch[1] : '?';
62
99
  const desc = truncateFirst(originalTask ?? 'fix in progress', 45);
63
100
  // Show constraint count when the chain has constraints to target (SDK 0.23.0)
64
- const chain = rec.wrfcId ? (() => { try { return deps.wrfcController.getChain(rec.wrfcId!); } catch { return null; } })() : null;
65
- const constraintCount = chain && chain.constraints.length > 0 ? chain.constraints.length : 0;
101
+ const chain = rec.wrfcId ? safeGetChain(rec.wrfcId, deps) : null;
102
+ const constraintCount = chain && (chain.constraints?.length ?? 0) > 0 ? chain.constraints?.length ?? 0 : 0;
66
103
  const constraintSuffix = constraintCount > 0 ? ` [${constraintCount}c]` : '';
67
104
  return `[Fix #${attempt}] ${desc} (${fromScore} \u2192 ${toScore}/10)${constraintSuffix}`;
68
105
  }
@@ -77,13 +114,331 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
77
114
  return `[${tag}] ${truncateFirst(task, maxDesc)}`;
78
115
  }
79
116
 
117
+ function isActiveAgent(rec: AgentRecord): boolean {
118
+ return rec.status !== 'completed' && rec.status !== 'failed' && rec.status !== 'cancelled';
119
+ }
120
+
121
+ function isActiveWrfcState(state: string): boolean {
122
+ return state !== 'passed' && state !== 'failed';
123
+ }
124
+
125
+ function getStreamSnippet(rec: AgentRecord): string | undefined {
126
+ if (!rec.streamingContent) return undefined;
127
+ const raw = rec.streamingContent.replace(/\n/g, ' ').trim();
128
+ return raw.length > 60 ? '...' + raw.slice(-57) : raw;
129
+ }
130
+
131
+ function compareAgents(a: AgentRecord, b: AgentRecord): number {
132
+ const roleDelta = (WRFC_ROLE_ORDER[a.wrfcRole ?? ''] ?? 50) - (WRFC_ROLE_ORDER[b.wrfcRole ?? ''] ?? 50);
133
+ if (roleDelta !== 0) return roleDelta;
134
+ return a.startedAt - b.startedAt || a.id.localeCompare(b.id);
135
+ }
136
+
137
+ function buildAgentEntry(
138
+ rec: AgentRecord,
139
+ deps: ProcessModalDeps,
140
+ now: number,
141
+ treePrefix = '',
142
+ ): ProcessEntry {
143
+ return {
144
+ id: rec.id,
145
+ label: buildAgentLabel(rec, deps),
146
+ treePrefix,
147
+ type: 'agent',
148
+ status: rec.status,
149
+ elapsedMs: now - rec.startedAt,
150
+ streamSnippet: getStreamSnippet(rec),
151
+ };
152
+ }
153
+
154
+ function appendAgentSubtree(
155
+ result: ProcessEntry[],
156
+ rec: AgentRecord,
157
+ childrenByParent: Map<string, AgentRecord[]>,
158
+ deps: ProcessModalDeps,
159
+ now: number,
160
+ prefix: string,
161
+ connector: string,
162
+ visited: Set<string>,
163
+ ): void {
164
+ if (visited.has(rec.id)) return;
165
+ visited.add(rec.id);
166
+ result.push(buildAgentEntry(rec, deps, now, `${prefix}${connector}`));
167
+
168
+ const children = (childrenByParent.get(rec.id) ?? []).slice().sort(compareAgents);
169
+ const descendantPrefix = connector === '├─ ' ? '│ ' : connector === '└─ ' ? ' ' : '';
170
+ children.forEach((child, index) => {
171
+ const last = index === children.length - 1;
172
+ appendAgentSubtree(
173
+ result,
174
+ child,
175
+ childrenByParent,
176
+ deps,
177
+ now,
178
+ `${prefix}${descendantPrefix}`,
179
+ last ? '└─ ' : '├─ ',
180
+ visited,
181
+ );
182
+ });
183
+ }
184
+
185
+ function appendAgentGroupEntries(
186
+ result: ProcessEntry[],
187
+ records: AgentRecord[],
188
+ deps: ProcessModalDeps,
189
+ now: number,
190
+ ): void {
191
+ const group = records.slice().sort(compareAgents);
192
+ const byId = new Map(group.map((rec) => [rec.id, rec]));
193
+ const childrenByParent = new Map<string, AgentRecord[]>();
194
+
195
+ for (const rec of group) {
196
+ if (!rec.parentAgentId || !byId.has(rec.parentAgentId)) continue;
197
+ const children = childrenByParent.get(rec.parentAgentId) ?? [];
198
+ children.push(rec);
199
+ childrenByParent.set(rec.parentAgentId, children);
200
+ }
201
+
202
+ const chain = group[0]?.wrfcId ? safeGetChain(group[0].wrfcId, deps) : null;
203
+ const owner = group.find((rec) => rec.id === chain?.ownerAgentId)
204
+ ?? group.find((rec) => rec.wrfcRole === 'owner');
205
+ const roots = owner
206
+ ? [owner]
207
+ : group.filter((rec) => !rec.parentAgentId || !byId.has(rec.parentAgentId));
208
+ const visited = new Set<string>();
209
+
210
+ roots.forEach((root, index) => {
211
+ const connector = owner || roots.length === 1 ? '' : (index === roots.length - 1 ? '└─ ' : '├─ ');
212
+ appendAgentSubtree(result, root, childrenByParent, deps, now, '', connector, visited);
213
+ });
214
+
215
+ const leftovers = group.filter((rec) => !visited.has(rec.id));
216
+ leftovers.forEach((rec, index) => {
217
+ appendAgentSubtree(
218
+ result,
219
+ rec,
220
+ childrenByParent,
221
+ deps,
222
+ now,
223
+ '',
224
+ index === leftovers.length - 1 ? '└─ ' : '├─ ',
225
+ visited,
226
+ );
227
+ });
228
+ }
229
+
230
+ function buildAgentEntries(
231
+ agents: AgentRecord[],
232
+ deps: ProcessModalDeps,
233
+ now: number,
234
+ getGroupOrder?: (key: string) => number | undefined,
235
+ ensureGroupOrder?: (key: string) => number,
236
+ ): ProcessEntry[] {
237
+ const result: ProcessEntry[] = [];
238
+ const displayAgents = prepareAgentRecordsForDisplay(agents, deps);
239
+ const activeById = new Map(displayAgents.map((agent) => [agent.id, agent]));
240
+ const groups = new Map<string, AgentRecord[]>();
241
+
242
+ for (const agent of displayAgents) {
243
+ const groupKey = getAgentGroupKey(agent, activeById);
244
+ const group = groups.get(groupKey) ?? [];
245
+ group.push(agent);
246
+ groups.set(groupKey, group);
247
+ }
248
+
249
+ const sortedGroups = Array.from(groups.entries()).sort(([aKey, a], [bKey, b]) => {
250
+ const aOrder = getGroupOrder?.(aKey);
251
+ const bOrder = getGroupOrder?.(bKey);
252
+ if (aOrder !== undefined || bOrder !== undefined) {
253
+ if (aOrder === undefined) return 1;
254
+ if (bOrder === undefined) return -1;
255
+ return aOrder - bOrder;
256
+ }
257
+ const aStarted = Math.min(...a.map((rec) => rec.startedAt));
258
+ const bStarted = Math.min(...b.map((rec) => rec.startedAt));
259
+ return aStarted - bStarted || aKey.localeCompare(bKey);
260
+ });
261
+ for (const [key, group] of sortedGroups) {
262
+ ensureGroupOrder?.(key);
263
+ appendAgentGroupEntries(result, group, deps, now);
264
+ }
265
+
266
+ return result;
267
+ }
268
+
269
+ function prepareAgentRecordsForDisplay(agents: AgentRecord[], deps: ProcessModalDeps): AgentRecord[] {
270
+ const chains = listWrfcChains(deps);
271
+ const agentById = new Map(agents.map((agent) => [agent.id, agent]));
272
+ const normalizedById = new Map<string, AgentRecord>();
273
+
274
+ for (const agent of agents) {
275
+ if (!isActiveAgent(agent)) continue;
276
+ normalizedById.set(agent.id, normalizeWrfcAgentRecord(agent, chains));
277
+ }
278
+
279
+ // A WRFC owner is the durable root of the chain. Keep it visible until the
280
+ // chain itself is terminal, even if the underlying owner agent has already
281
+ // emitted a completed phase event before reviewer/fixer/gate work finishes.
282
+ for (const chain of chains) {
283
+ if (!isActiveWrfcState(chain.state)) continue;
284
+ const owner = agentById.get(chain.ownerAgentId);
285
+ if (!owner || normalizedById.has(owner.id)) continue;
286
+ const chainHasActiveMember = agents.some((agent) =>
287
+ agent.id !== owner.id
288
+ && isActiveAgent(agent)
289
+ && isAgentInChain(agent, chain)
290
+ );
291
+ if (!chainHasActiveMember) continue;
292
+ normalizedById.set(owner.id, normalizeWrfcAgentRecord({
293
+ ...owner,
294
+ status: 'running',
295
+ completedAt: undefined,
296
+ progress: owner.progress ?? `WRFC chain ${chain.state}`,
297
+ }, chains));
298
+ }
299
+
300
+ const normalized = Array.from(normalizedById.values());
301
+ return inferDuplicateWrfcOwnerRows(normalized);
302
+ }
303
+
304
+ function normalizeWrfcAgentRecord(agent: AgentRecord, chains: WrfcChainLike[]): AgentRecord {
305
+ const chain = findChainForAgent(agent, chains);
306
+ if (!chain) return agent;
307
+
308
+ const role = inferWrfcRole(agent, chain);
309
+ const parentAgentId = role && role !== 'owner'
310
+ ? agent.parentAgentId ?? chain.ownerAgentId
311
+ : agent.parentAgentId;
312
+
313
+ return {
314
+ ...agent,
315
+ wrfcId: agent.wrfcId ?? chain.id,
316
+ wrfcRole: agent.wrfcRole ?? role,
317
+ parentAgentId,
318
+ };
319
+ }
320
+
321
+ function inferDuplicateWrfcOwnerRows(agents: AgentRecord[]): AgentRecord[] {
322
+ const byTask = new Map<string, AgentRecord[]>();
323
+ for (const agent of agents) {
324
+ if (agent.wrfcId || agent.wrfcRole || agent.parentAgentId) continue;
325
+ if (agent.reviewMode !== 'wrfc') continue;
326
+ const key = agent.task.trim();
327
+ if (!key) continue;
328
+ const group = byTask.get(key) ?? [];
329
+ group.push(agent);
330
+ byTask.set(key, group);
331
+ }
332
+
333
+ const inferredIds = new Set<string>();
334
+ const inferred = new Map<string, AgentRecord>();
335
+ for (const [task, group] of byTask) {
336
+ if (group.length < 2) continue;
337
+ const sorted = group.slice().sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
338
+ const owner = sorted[0]!;
339
+ const syntheticWrfcId = `inferred:${owner.id}`;
340
+ inferred.set(owner.id, {
341
+ ...owner,
342
+ wrfcId: syntheticWrfcId,
343
+ wrfcRole: 'owner',
344
+ });
345
+ inferredIds.add(owner.id);
346
+ for (const child of sorted.slice(1)) {
347
+ inferred.set(child.id, {
348
+ ...child,
349
+ wrfcId: syntheticWrfcId,
350
+ wrfcRole: child.template === 'reviewer' ? 'reviewer' : 'engineer',
351
+ parentAgentId: owner.id,
352
+ });
353
+ inferredIds.add(child.id);
354
+ }
355
+
356
+ // Avoid accidentally grouping unrelated long-running WRFC roots that just
357
+ // happen to share an empty or generic task after this exact duplicate group.
358
+ byTask.delete(task);
359
+ }
360
+
361
+ if (inferredIds.size === 0) return agents;
362
+ return agents.map((agent) => inferred.get(agent.id) ?? agent);
363
+ }
364
+
365
+ function listWrfcChains(deps: ProcessModalDeps): WrfcChainLike[] {
366
+ const controller = deps.wrfcController as ProcessModalDeps['wrfcController'] & {
367
+ listChains?: () => unknown;
368
+ };
369
+ if (typeof controller.listChains !== 'function') return [];
370
+ try {
371
+ const value = controller.listChains();
372
+ return Array.isArray(value) ? value.filter(isWrfcChainLike) : [];
373
+ } catch {
374
+ return [];
375
+ }
376
+ }
377
+
378
+ function isWrfcChainLike(value: unknown): value is WrfcChainLike {
379
+ if (!value || typeof value !== 'object') return false;
380
+ const record = value as Record<string, unknown>;
381
+ return typeof record.id === 'string'
382
+ && typeof record.state === 'string'
383
+ && typeof record.task === 'string'
384
+ && typeof record.ownerAgentId === 'string';
385
+ }
386
+
387
+ function findChainForAgent(agent: AgentRecord, chains: WrfcChainLike[]): WrfcChainLike | null {
388
+ if (agent.wrfcId) {
389
+ const direct = chains.find((chain) => chain.id === agent.wrfcId);
390
+ if (direct) return direct;
391
+ }
392
+ return chains.find((chain) => isAgentInChain(agent, chain)) ?? null;
393
+ }
394
+
395
+ function isAgentInChain(agent: AgentRecord, chain: WrfcChainLike): boolean {
396
+ return chain.ownerAgentId === agent.id
397
+ || chain.engineerAgentId === agent.id
398
+ || chain.reviewerAgentId === agent.id
399
+ || chain.fixerAgentId === agent.id
400
+ || (chain.allAgentIds?.includes(agent.id) ?? false)
401
+ || agent.wrfcId === chain.id;
402
+ }
403
+
404
+ function inferWrfcRole(agent: AgentRecord, chain: WrfcChainLike): AgentRecord['wrfcRole'] {
405
+ if (agent.wrfcRole) return agent.wrfcRole;
406
+ if (chain.ownerAgentId === agent.id) return 'owner';
407
+ if (chain.engineerAgentId === agent.id) return 'engineer';
408
+ if (chain.reviewerAgentId === agent.id) return 'reviewer';
409
+ if (chain.fixerAgentId === agent.id) return 'fixer';
410
+ if (agent.template === 'reviewer') return 'reviewer';
411
+ return 'engineer';
412
+ }
413
+
414
+ function getAgentGroupKey(agent: AgentRecord, activeById: Map<string, AgentRecord>): string {
415
+ if (agent.wrfcId) return `wrfc:${agent.wrfcId}`;
416
+
417
+ const seen = new Set<string>();
418
+ let root = agent;
419
+ while (root.parentAgentId && activeById.has(root.parentAgentId) && !seen.has(root.parentAgentId)) {
420
+ seen.add(root.id);
421
+ root = activeById.get(root.parentAgentId)!;
422
+ }
423
+
424
+ // If the active root is an orphaned child, keep it anchored to its missing parent id
425
+ // so it does not jump to a new group when the parent exits before its children.
426
+ return `root:${root.parentAgentId ?? root.id}`;
427
+ }
428
+
429
+ function safeGetChain(wrfcId: string, deps: Pick<ProcessModalDeps, 'wrfcController'>): WrfcChainLike | null {
430
+ try {
431
+ const chain = deps.wrfcController.getChain(wrfcId);
432
+ return isWrfcChainLike(chain) ? chain : null;
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+
80
438
  /** Get the original task description from a WRFC chain. */
81
439
  function getChainTask(wrfcId: string | undefined, deps: Pick<ProcessModalDeps, 'wrfcController'>): string | null {
82
440
  if (!wrfcId) return null;
83
- try {
84
- const chain = deps.wrfcController.getChain(wrfcId);
85
- return chain?.task ?? null;
86
- } catch { return null; }
441
+ return safeGetChain(wrfcId, deps)?.task ?? null;
87
442
  }
88
443
 
89
444
  /** Truncate to first line, capped at max chars. */
@@ -113,6 +468,8 @@ export class ProcessModal {
113
468
  public entries: ProcessEntry[] = [];
114
469
  private refreshTimer: ReturnType<typeof setInterval> | null = null;
115
470
  private onRefresh: (() => void) | null = null;
471
+ private groupOrder = new Map<string, number>();
472
+ private nextGroupOrder = 0;
116
473
 
117
474
  constructor(private readonly deps: ProcessModalDeps) {}
118
475
 
@@ -147,23 +504,14 @@ export class ProcessModal {
147
504
  const now = Date.now();
148
505
  const result: ProcessEntry[] = [];
149
506
 
150
- // Agents — only show active (pending/running)
151
- for (const a of manager.list()) {
152
- if (a.status === 'completed' || a.status === 'failed' || a.status === 'cancelled') continue;
153
- let streamSnippet: string | undefined;
154
- if (a.streamingContent) {
155
- const raw = a.streamingContent.replace(/\n/g, ' ').trim();
156
- streamSnippet = raw.length > 60 ? '...' + raw.slice(-57) : raw;
157
- }
158
- result.push({
159
- id: a.id,
160
- label: buildAgentLabel(a, this.deps),
161
- type: 'agent',
162
- status: a.status,
163
- elapsedMs: now - a.startedAt,
164
- streamSnippet,
165
- });
166
- }
507
+ // Agents — only show active (pending/running), grouped by stable parent/child hierarchy.
508
+ result.push(...buildAgentEntries(
509
+ manager.list(),
510
+ this.deps,
511
+ now,
512
+ (key) => this.groupOrder.get(key),
513
+ (key) => this.ensureGroupOrder(key),
514
+ ));
167
515
 
168
516
  // Background exec processes — only show running
169
517
  const pm = this.deps.processManager;
@@ -187,6 +535,14 @@ export class ProcessModal {
187
535
  }
188
536
  }
189
537
 
538
+ private ensureGroupOrder(key: string): number {
539
+ const existing = this.groupOrder.get(key);
540
+ if (existing !== undefined) return existing;
541
+ const next = this.nextGroupOrder++;
542
+ this.groupOrder.set(key, next);
543
+ return next;
544
+ }
545
+
190
546
  moveUp(): void {
191
547
  if (this.entries.length === 0) return;
192
548
  this.selectedIndex = (this.selectedIndex - 1 + this.entries.length) % this.entries.length;
@@ -270,9 +626,10 @@ export function renderProcessModal(modal: ProcessModal, width: number, viewportH
270
626
  const dur = formatDuration(e.elapsedMs);
271
627
  const statusStr = e.streamSnippet ? `streaming ${dur}` : `${e.status} ${dur}`;
272
628
  const suffix = ` ${statusStr}`;
273
- const maxDescW = maxLabelW - typeTag.length - suffix.length - 4; // icon + spaces
629
+ const treePrefix = e.treePrefix ?? '';
630
+ const maxDescW = maxLabelW - typeTag.length - treePrefix.length - suffix.length - 4; // icon + spaces
274
631
  const desc = e.label.length > maxDescW ? e.label.slice(0, Math.max(0, maxDescW - 3)) + '...' : e.label;
275
- const label = `${statusIcon} ${typeTag} ${desc}${suffix}`;
632
+ const label = `${statusIcon} ${typeTag} ${treePrefix}${desc}${suffix}`;
276
633
  return {
277
634
  label,
278
635
  selected: absoluteIndex === modal.selectedIndex,
@@ -0,0 +1,67 @@
1
+ export type ProcessSummaryAgent = {
2
+ readonly id: string;
3
+ readonly progress?: string;
4
+ };
5
+
6
+ export type RuntimeProcessSummaryAgent = {
7
+ readonly id: string;
8
+ readonly latestProgress?: string;
9
+ };
10
+
11
+ export type WrfcProcessSummaryChain = {
12
+ readonly state?: string;
13
+ readonly ownerAgentId?: string;
14
+ readonly engineerAgentId?: string;
15
+ readonly reviewerAgentId?: string;
16
+ readonly fixerAgentId?: string;
17
+ readonly allAgentIds?: readonly unknown[];
18
+ };
19
+
20
+ export type RunningAgentSummary = {
21
+ readonly count: number;
22
+ readonly progress?: string;
23
+ };
24
+
25
+ export function summarizeRunningAgents(
26
+ managerAgents: readonly ProcessSummaryAgent[],
27
+ runtimeAgents: readonly RuntimeProcessSummaryAgent[],
28
+ wrfcChains: readonly WrfcProcessSummaryChain[],
29
+ ): RunningAgentSummary {
30
+ const runningAgentIds = new Set<string>();
31
+ let progress: string | undefined;
32
+
33
+ for (const agent of managerAgents) {
34
+ runningAgentIds.add(agent.id);
35
+ if (!progress && agent.progress) progress = agent.progress;
36
+ }
37
+
38
+ for (const agent of runtimeAgents) {
39
+ runningAgentIds.add(agent.id);
40
+ if (!progress && agent.latestProgress) progress = agent.latestProgress;
41
+ }
42
+
43
+ for (const chain of wrfcChains) {
44
+ if (isTerminalWrfcState(chain.state)) continue;
45
+ const chainAgentIds = collectChainAgentIds(chain);
46
+ const hasVisibleChainWork = Array.from(chainAgentIds).some((id) => runningAgentIds.has(id));
47
+ if (!hasVisibleChainWork || !chain.ownerAgentId) continue;
48
+ runningAgentIds.add(chain.ownerAgentId);
49
+ if (!progress) progress = `WRFC chain ${chain.state ?? 'running'}`;
50
+ }
51
+
52
+ return { count: runningAgentIds.size, progress };
53
+ }
54
+
55
+ function isTerminalWrfcState(state: string | undefined): boolean {
56
+ return state === 'passed' || state === 'failed';
57
+ }
58
+
59
+ function collectChainAgentIds(chain: WrfcProcessSummaryChain): Set<string> {
60
+ return new Set([
61
+ chain.ownerAgentId,
62
+ chain.engineerAgentId,
63
+ chain.reviewerAgentId,
64
+ chain.fixerAgentId,
65
+ ...(chain.allAgentIds ?? []),
66
+ ].filter((id): id is string => typeof id === 'string' && id.length > 0));
67
+ }