@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/CHANGELOG.md +13 -0
- package/README.md +3 -3
- package/docs/foundation-artifacts/operator-contract.json +5009 -290
- package/package.json +2 -2
- package/src/main.ts +4 -13
- package/src/renderer/process-modal.ts +383 -26
- package/src/renderer/process-summary.ts +67 -0
- package/src/tools/wrfc-agent-guard.ts +8 -82
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.19.
|
|
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.
|
|
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
|
|
487
|
-
|
|
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 ? (
|
|
65
|
-
const constraintCount = chain && chain.constraints
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
+
}
|