@pellux/goodvibes-agent 0.1.101 → 0.1.103
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 +14 -0
- package/README.md +10 -0
- package/docs/README.md +1 -1
- package/docs/getting-started.md +17 -3
- package/package.json +1 -18
- package/src/cli/help.ts +86 -0
- package/src/cli/local-library-command.ts +516 -0
- package/src/cli/management.ts +17 -0
- package/src/cli/memory-command.ts +646 -0
- package/src/cli/package-verification.ts +10 -0
- package/src/cli/parser.ts +8 -0
- package/src/cli/types.ts +3 -0
- package/src/input/agent-workspace-setup.ts +2 -2
- package/src/input/agent-workspace-snapshot.ts +4 -4
- package/src/input/agent-workspace-types.ts +2 -2
- package/src/input/command-registry.ts +0 -8
- package/src/input/feed-context-factory.ts +1 -3
- package/src/input/handler-feed.ts +1 -4
- package/src/input/handler-interactions.ts +0 -1
- package/src/input/handler-modal-stack.ts +0 -1
- package/src/input/handler-modal-token-routes.ts +0 -11
- package/src/input/handler-picker-routes.ts +11 -20
- package/src/input/handler-ui-state.ts +0 -6
- package/src/input/handler.ts +1 -17
- package/src/main.ts +0 -6
- package/src/panels/builtin/agent.ts +0 -17
- package/src/panels/index.ts +0 -2
- package/src/renderer/agent-workspace.ts +3 -3
- package/src/renderer/conversation-overlays.ts +0 -6
- package/src/renderer/live-tail-modal.ts +10 -69
- package/src/renderer/process-modal.ts +28 -530
- package/src/runtime/bootstrap-command-parts.ts +0 -28
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap.ts +3 -12
- package/src/runtime/services.ts +3 -4
- package/src/tools/{wrfc-agent-guard.ts → agent-tool-policy-guard.ts} +0 -6
- package/src/version.ts +1 -1
- package/src/panels/agent-inspector-panel.ts +0 -521
- package/src/panels/agent-inspector-shared.ts +0 -94
- package/src/panels/agent-logs-panel.ts +0 -559
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/renderer/agent-detail-modal.ts +0 -331
- package/src/renderer/process-summary.ts +0 -67
|
@@ -2,484 +2,39 @@ import { type Line } from '../types/grid.ts';
|
|
|
2
2
|
import { ModalFactory } from './modal-factory.ts';
|
|
3
3
|
import { formatDuration } from './modal-utils.ts';
|
|
4
4
|
import type { ProcessManager } from '@pellux/goodvibes-sdk/platform/tools';
|
|
5
|
-
import type { AgentManager, AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
|
|
6
|
-
import type { WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
|
|
7
5
|
import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
|
|
8
6
|
import { getVisibleWindow } from './surface-layout.ts';
|
|
9
7
|
|
|
10
|
-
// ─── ProcessEntry ─────────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
8
|
export interface ProcessEntry {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
treePrefix?: string;
|
|
19
|
-
/** Process type */
|
|
20
|
-
type: 'agent' | 'exec';
|
|
21
|
-
/** Current status string */
|
|
22
|
-
status: string;
|
|
23
|
-
/** Elapsed milliseconds since start */
|
|
24
|
-
elapsedMs: number;
|
|
25
|
-
/** Live streaming snippet for tracked delegated sessions (last ~60 chars of current turn output). */
|
|
26
|
-
streamSnippet?: string;
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly type: 'exec';
|
|
12
|
+
readonly status: string;
|
|
13
|
+
readonly elapsedMs: number;
|
|
27
14
|
}
|
|
28
15
|
|
|
29
|
-
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
/** Maximum characters from agent task / exec command stored in ProcessEntry.label. */
|
|
32
16
|
const MAX_LABEL_LENGTH = 80;
|
|
33
|
-
/** Border and margin width subtracted from terminal width to get modal content width. */
|
|
34
17
|
const MODAL_BORDER_WIDTH = 8;
|
|
35
18
|
|
|
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
|
-
|
|
44
19
|
export interface ProcessModalDeps {
|
|
45
|
-
readonly agentManager: Pick<AgentManager, 'list' | 'getStatus'>;
|
|
46
20
|
readonly processManager: Pick<ProcessManager, 'list' | 'getStatus' | 'stop'>;
|
|
47
|
-
readonly wrfcController: Pick<WrfcController, 'getChain'> & Partial<Pick<WrfcController, 'listChains'>>;
|
|
48
|
-
/**
|
|
49
|
-
* GoodVibes Agent must not present local AgentManager records as an owned
|
|
50
|
-
* execution lane. Tests for copied TUI primitives can opt into read-only
|
|
51
|
-
* display, but product runtime should keep this hidden.
|
|
52
|
-
*/
|
|
53
|
-
readonly agentEntries: 'hidden' | 'read-only';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
type WrfcChainLike = {
|
|
57
|
-
readonly id: string;
|
|
58
|
-
readonly state: string;
|
|
59
|
-
readonly task: string;
|
|
60
|
-
readonly ownerAgentId: string;
|
|
61
|
-
readonly engineerAgentId?: string;
|
|
62
|
-
readonly reviewerAgentId?: string;
|
|
63
|
-
readonly fixerAgentId?: string;
|
|
64
|
-
readonly allAgentIds?: readonly string[];
|
|
65
|
-
readonly constraints?: readonly unknown[];
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
/** Build a display label for an agent based on its task and template. */
|
|
69
|
-
function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
|
|
70
|
-
const task = rec.task;
|
|
71
|
-
|
|
72
|
-
// Look up the original task from the WRFC chain if available
|
|
73
|
-
const originalTask = getChainTask(rec.wrfcId, deps);
|
|
74
|
-
|
|
75
|
-
if (rec.wrfcRole === 'owner') {
|
|
76
|
-
const desc = truncateFirst(originalTask ?? task, MAX_LABEL_LENGTH - 13);
|
|
77
|
-
return `[WRFC owner] ${desc}`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (rec.wrfcRole === 'engineer') {
|
|
81
|
-
const desc = truncateFirst(originalTask ?? task, MAX_LABEL_LENGTH - 11);
|
|
82
|
-
return `[Engineer] ${desc}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (rec.wrfcRole === 'verifier') {
|
|
86
|
-
const desc = truncateFirst(originalTask ?? task, MAX_LABEL_LENGTH - 13);
|
|
87
|
-
return `[Verifier] ${desc}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// WRFC Review agent
|
|
91
|
-
if (task.startsWith('WRFC Review Request')) {
|
|
92
|
-
const thresholdMatch = task.match(/threshold is (\d+(?:\.\d+)?)/);
|
|
93
|
-
const threshold = thresholdMatch ? thresholdMatch[1] : '9.9';
|
|
94
|
-
const desc = truncateFirst(originalTask ?? 'review in progress', 50);
|
|
95
|
-
return `[Review] ${desc} (target: ${threshold}/10)`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// WRFC Fix agent
|
|
99
|
-
if (task.startsWith('WRFC Fix Request')) {
|
|
100
|
-
const scoreMatch = task.match(/Review score:\s*(\d+(?:\.\d+)?)\/(\d+)\s*\(threshold:\s*(\d+(?:\.\d+)?)/);
|
|
101
|
-
const fromScore = scoreMatch ? scoreMatch[1] : '?';
|
|
102
|
-
const toScore = scoreMatch ? scoreMatch[3] : '?';
|
|
103
|
-
const attemptMatch = task.match(/Fix attempt:\s*(\d+)/);
|
|
104
|
-
const attempt = attemptMatch ? attemptMatch[1] : '?';
|
|
105
|
-
const desc = truncateFirst(originalTask ?? 'fix in progress', 45);
|
|
106
|
-
// Show constraint count when the chain has constraints to target (SDK 0.23.0)
|
|
107
|
-
const chain = rec.wrfcId ? safeGetChain(rec.wrfcId, deps) : null;
|
|
108
|
-
const constraintCount = chain && (chain.constraints?.length ?? 0) > 0 ? chain.constraints?.length ?? 0 : 0;
|
|
109
|
-
const constraintSuffix = constraintCount > 0 ? ` [${constraintCount}c]` : '';
|
|
110
|
-
return `[Fix #${attempt}] ${desc} (${fromScore} \u2192 ${toScore}/10)${constraintSuffix}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Regular agent — show template and truncated first line
|
|
114
|
-
const templateLabels: Record<string, string> = {
|
|
115
|
-
engineer: 'Engineer', reviewer: 'Reviewer', tester: 'Tester',
|
|
116
|
-
researcher: 'Researcher', general: 'Agent',
|
|
117
|
-
};
|
|
118
|
-
const tag = templateLabels[rec.template] ?? 'Agent';
|
|
119
|
-
const maxDesc = MAX_LABEL_LENGTH - tag.length - 3;
|
|
120
|
-
return `[${tag}] ${truncateFirst(task, maxDesc)}`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function isActiveAgent(rec: AgentRecord): boolean {
|
|
124
|
-
return rec.status !== 'completed' && rec.status !== 'failed' && rec.status !== 'cancelled';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function isActiveWrfcState(state: string): boolean {
|
|
128
|
-
return state !== 'passed' && state !== 'failed';
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function getStreamSnippet(rec: AgentRecord): string | undefined {
|
|
132
|
-
if (!rec.streamingContent) return undefined;
|
|
133
|
-
const raw = rec.streamingContent.replace(/\n/g, ' ').trim();
|
|
134
|
-
return raw.length > 60 ? '...' + raw.slice(-57) : raw;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function compareAgents(a: AgentRecord, b: AgentRecord): number {
|
|
138
|
-
const roleDelta = (WRFC_ROLE_ORDER[a.wrfcRole ?? ''] ?? 50) - (WRFC_ROLE_ORDER[b.wrfcRole ?? ''] ?? 50);
|
|
139
|
-
if (roleDelta !== 0) return roleDelta;
|
|
140
|
-
return a.startedAt - b.startedAt || a.id.localeCompare(b.id);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function buildAgentEntry(
|
|
144
|
-
rec: AgentRecord,
|
|
145
|
-
deps: ProcessModalDeps,
|
|
146
|
-
now: number,
|
|
147
|
-
treePrefix = '',
|
|
148
|
-
): ProcessEntry {
|
|
149
|
-
return {
|
|
150
|
-
id: rec.id,
|
|
151
|
-
label: buildAgentLabel(rec, deps),
|
|
152
|
-
treePrefix,
|
|
153
|
-
type: 'agent',
|
|
154
|
-
status: rec.status,
|
|
155
|
-
elapsedMs: now - rec.startedAt,
|
|
156
|
-
streamSnippet: getStreamSnippet(rec),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function appendAgentSubtree(
|
|
161
|
-
result: ProcessEntry[],
|
|
162
|
-
rec: AgentRecord,
|
|
163
|
-
childrenByParent: Map<string, AgentRecord[]>,
|
|
164
|
-
deps: ProcessModalDeps,
|
|
165
|
-
now: number,
|
|
166
|
-
prefix: string,
|
|
167
|
-
connector: string,
|
|
168
|
-
visited: Set<string>,
|
|
169
|
-
): void {
|
|
170
|
-
if (visited.has(rec.id)) return;
|
|
171
|
-
visited.add(rec.id);
|
|
172
|
-
result.push(buildAgentEntry(rec, deps, now, `${prefix}${connector}`));
|
|
173
|
-
|
|
174
|
-
const children = (childrenByParent.get(rec.id) ?? []).slice().sort(compareAgents);
|
|
175
|
-
const descendantPrefix = connector === '├─ ' ? '│ ' : connector === '└─ ' ? ' ' : '';
|
|
176
|
-
children.forEach((child, index) => {
|
|
177
|
-
const last = index === children.length - 1;
|
|
178
|
-
appendAgentSubtree(
|
|
179
|
-
result,
|
|
180
|
-
child,
|
|
181
|
-
childrenByParent,
|
|
182
|
-
deps,
|
|
183
|
-
now,
|
|
184
|
-
`${prefix}${descendantPrefix}`,
|
|
185
|
-
last ? '└─ ' : '├─ ',
|
|
186
|
-
visited,
|
|
187
|
-
);
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function appendAgentGroupEntries(
|
|
192
|
-
result: ProcessEntry[],
|
|
193
|
-
records: AgentRecord[],
|
|
194
|
-
deps: ProcessModalDeps,
|
|
195
|
-
now: number,
|
|
196
|
-
): void {
|
|
197
|
-
const group = records.slice().sort(compareAgents);
|
|
198
|
-
const byId = new Map(group.map((rec) => [rec.id, rec]));
|
|
199
|
-
const childrenByParent = new Map<string, AgentRecord[]>();
|
|
200
|
-
|
|
201
|
-
for (const rec of group) {
|
|
202
|
-
if (!rec.parentAgentId || !byId.has(rec.parentAgentId)) continue;
|
|
203
|
-
const children = childrenByParent.get(rec.parentAgentId) ?? [];
|
|
204
|
-
children.push(rec);
|
|
205
|
-
childrenByParent.set(rec.parentAgentId, children);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const chain = group[0]?.wrfcId ? safeGetChain(group[0].wrfcId, deps) : null;
|
|
209
|
-
const owner = group.find((rec) => rec.id === chain?.ownerAgentId)
|
|
210
|
-
?? group.find((rec) => rec.wrfcRole === 'owner');
|
|
211
|
-
const roots = owner
|
|
212
|
-
? [owner]
|
|
213
|
-
: group.filter((rec) => !rec.parentAgentId || !byId.has(rec.parentAgentId));
|
|
214
|
-
const visited = new Set<string>();
|
|
215
|
-
|
|
216
|
-
roots.forEach((root, index) => {
|
|
217
|
-
const connector = owner || roots.length === 1 ? '' : (index === roots.length - 1 ? '└─ ' : '├─ ');
|
|
218
|
-
appendAgentSubtree(result, root, childrenByParent, deps, now, '', connector, visited);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const leftovers = group.filter((rec) => !visited.has(rec.id));
|
|
222
|
-
leftovers.forEach((rec, index) => {
|
|
223
|
-
appendAgentSubtree(
|
|
224
|
-
result,
|
|
225
|
-
rec,
|
|
226
|
-
childrenByParent,
|
|
227
|
-
deps,
|
|
228
|
-
now,
|
|
229
|
-
'',
|
|
230
|
-
index === leftovers.length - 1 ? '└─ ' : '├─ ',
|
|
231
|
-
visited,
|
|
232
|
-
);
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function buildAgentEntries(
|
|
237
|
-
agents: AgentRecord[],
|
|
238
|
-
deps: ProcessModalDeps,
|
|
239
|
-
now: number,
|
|
240
|
-
getGroupOrder?: (key: string) => number | undefined,
|
|
241
|
-
ensureGroupOrder?: (key: string) => number,
|
|
242
|
-
): ProcessEntry[] {
|
|
243
|
-
const result: ProcessEntry[] = [];
|
|
244
|
-
const displayAgents = prepareAgentRecordsForDisplay(agents, deps);
|
|
245
|
-
const activeById = new Map(displayAgents.map((agent) => [agent.id, agent]));
|
|
246
|
-
const groups = new Map<string, AgentRecord[]>();
|
|
247
|
-
|
|
248
|
-
for (const agent of displayAgents) {
|
|
249
|
-
const groupKey = getAgentGroupKey(agent, activeById);
|
|
250
|
-
const group = groups.get(groupKey) ?? [];
|
|
251
|
-
group.push(agent);
|
|
252
|
-
groups.set(groupKey, group);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const sortedGroups = Array.from(groups.entries()).sort(([aKey, a], [bKey, b]) => {
|
|
256
|
-
const aOrder = getGroupOrder?.(aKey);
|
|
257
|
-
const bOrder = getGroupOrder?.(bKey);
|
|
258
|
-
if (aOrder !== undefined || bOrder !== undefined) {
|
|
259
|
-
if (aOrder === undefined) return 1;
|
|
260
|
-
if (bOrder === undefined) return -1;
|
|
261
|
-
return aOrder - bOrder;
|
|
262
|
-
}
|
|
263
|
-
const aStarted = Math.min(...a.map((rec) => rec.startedAt));
|
|
264
|
-
const bStarted = Math.min(...b.map((rec) => rec.startedAt));
|
|
265
|
-
return aStarted - bStarted || aKey.localeCompare(bKey);
|
|
266
|
-
});
|
|
267
|
-
for (const [key, group] of sortedGroups) {
|
|
268
|
-
ensureGroupOrder?.(key);
|
|
269
|
-
appendAgentGroupEntries(result, group, deps, now);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return result;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function prepareAgentRecordsForDisplay(agents: AgentRecord[], deps: ProcessModalDeps): AgentRecord[] {
|
|
276
|
-
const chains = listWrfcChains(deps);
|
|
277
|
-
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
|
|
278
|
-
const normalizedById = new Map<string, AgentRecord>();
|
|
279
|
-
|
|
280
|
-
for (const agent of agents) {
|
|
281
|
-
if (!isActiveAgent(agent)) continue;
|
|
282
|
-
normalizedById.set(agent.id, normalizeWrfcAgentRecord(agent, chains));
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// A WRFC owner is the durable root of the chain. Keep it visible until the
|
|
286
|
-
// chain itself is terminal, even if the underlying owner agent has already
|
|
287
|
-
// emitted a completed phase event before reviewer/fixer/gate work finishes.
|
|
288
|
-
for (const chain of chains) {
|
|
289
|
-
if (!isActiveWrfcState(chain.state)) continue;
|
|
290
|
-
const owner = agentById.get(chain.ownerAgentId);
|
|
291
|
-
if (!owner || normalizedById.has(owner.id)) continue;
|
|
292
|
-
const chainHasActiveMember = agents.some((agent) =>
|
|
293
|
-
agent.id !== owner.id
|
|
294
|
-
&& isActiveAgent(agent)
|
|
295
|
-
&& isAgentInChain(agent, chain)
|
|
296
|
-
);
|
|
297
|
-
if (!chainHasActiveMember) continue;
|
|
298
|
-
normalizedById.set(owner.id, normalizeWrfcAgentRecord({
|
|
299
|
-
...owner,
|
|
300
|
-
status: 'running',
|
|
301
|
-
completedAt: undefined,
|
|
302
|
-
progress: owner.progress ?? `WRFC chain ${chain.state}`,
|
|
303
|
-
}, chains));
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const normalized = Array.from(normalizedById.values());
|
|
307
|
-
return inferDuplicateWrfcOwnerRows(normalized);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function normalizeWrfcAgentRecord(agent: AgentRecord, chains: WrfcChainLike[]): AgentRecord {
|
|
311
|
-
const chain = findChainForAgent(agent, chains);
|
|
312
|
-
if (!chain) return agent;
|
|
313
|
-
|
|
314
|
-
const role = inferWrfcRole(agent, chain);
|
|
315
|
-
const parentAgentId = role && role !== 'owner'
|
|
316
|
-
? agent.parentAgentId ?? chain.ownerAgentId
|
|
317
|
-
: agent.parentAgentId;
|
|
318
|
-
|
|
319
|
-
return {
|
|
320
|
-
...agent,
|
|
321
|
-
wrfcId: agent.wrfcId ?? chain.id,
|
|
322
|
-
wrfcRole: agent.wrfcRole ?? role,
|
|
323
|
-
parentAgentId,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function inferDuplicateWrfcOwnerRows(agents: AgentRecord[]): AgentRecord[] {
|
|
328
|
-
const byTask = new Map<string, AgentRecord[]>();
|
|
329
|
-
for (const agent of agents) {
|
|
330
|
-
if (agent.wrfcId || agent.wrfcRole || agent.parentAgentId) continue;
|
|
331
|
-
if (agent.reviewMode !== 'wrfc') continue;
|
|
332
|
-
const key = agent.task.trim();
|
|
333
|
-
if (!key) continue;
|
|
334
|
-
const group = byTask.get(key) ?? [];
|
|
335
|
-
group.push(agent);
|
|
336
|
-
byTask.set(key, group);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const inferredIds = new Set<string>();
|
|
340
|
-
const inferred = new Map<string, AgentRecord>();
|
|
341
|
-
for (const [task, group] of byTask) {
|
|
342
|
-
if (group.length < 2) continue;
|
|
343
|
-
const sorted = group.slice().sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
|
|
344
|
-
const owner = sorted[0]!;
|
|
345
|
-
const syntheticWrfcId = `inferred:${owner.id}`;
|
|
346
|
-
inferred.set(owner.id, {
|
|
347
|
-
...owner,
|
|
348
|
-
wrfcId: syntheticWrfcId,
|
|
349
|
-
wrfcRole: 'owner',
|
|
350
|
-
});
|
|
351
|
-
inferredIds.add(owner.id);
|
|
352
|
-
for (const child of sorted.slice(1)) {
|
|
353
|
-
inferred.set(child.id, {
|
|
354
|
-
...child,
|
|
355
|
-
wrfcId: syntheticWrfcId,
|
|
356
|
-
wrfcRole: child.template === 'reviewer' ? 'reviewer' : 'engineer',
|
|
357
|
-
parentAgentId: owner.id,
|
|
358
|
-
});
|
|
359
|
-
inferredIds.add(child.id);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Avoid accidentally grouping unrelated long-running WRFC roots that just
|
|
363
|
-
// happen to share an empty or generic task after this exact duplicate group.
|
|
364
|
-
byTask.delete(task);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (inferredIds.size === 0) return agents;
|
|
368
|
-
return agents.map((agent) => inferred.get(agent.id) ?? agent);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function listWrfcChains(deps: ProcessModalDeps): WrfcChainLike[] {
|
|
372
|
-
const controller = deps.wrfcController as ProcessModalDeps['wrfcController'] & {
|
|
373
|
-
listChains?: () => unknown;
|
|
374
|
-
};
|
|
375
|
-
if (typeof controller.listChains !== 'function') return [];
|
|
376
|
-
try {
|
|
377
|
-
const value = controller.listChains();
|
|
378
|
-
return Array.isArray(value) ? value.filter(isWrfcChainLike) : [];
|
|
379
|
-
} catch {
|
|
380
|
-
return [];
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function isWrfcChainLike(value: unknown): value is WrfcChainLike {
|
|
385
|
-
if (!value || typeof value !== 'object') return false;
|
|
386
|
-
const record = value as Record<string, unknown>;
|
|
387
|
-
return typeof record.id === 'string'
|
|
388
|
-
&& typeof record.state === 'string'
|
|
389
|
-
&& typeof record.task === 'string'
|
|
390
|
-
&& typeof record.ownerAgentId === 'string';
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function findChainForAgent(agent: AgentRecord, chains: WrfcChainLike[]): WrfcChainLike | null {
|
|
394
|
-
if (agent.wrfcId) {
|
|
395
|
-
const direct = chains.find((chain) => chain.id === agent.wrfcId);
|
|
396
|
-
if (direct) return direct;
|
|
397
|
-
}
|
|
398
|
-
return chains.find((chain) => isAgentInChain(agent, chain)) ?? null;
|
|
399
21
|
}
|
|
400
22
|
|
|
401
|
-
function isAgentInChain(agent: AgentRecord, chain: WrfcChainLike): boolean {
|
|
402
|
-
return chain.ownerAgentId === agent.id
|
|
403
|
-
|| chain.engineerAgentId === agent.id
|
|
404
|
-
|| chain.reviewerAgentId === agent.id
|
|
405
|
-
|| chain.fixerAgentId === agent.id
|
|
406
|
-
|| (chain.allAgentIds?.includes(agent.id) ?? false)
|
|
407
|
-
|| agent.wrfcId === chain.id;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function inferWrfcRole(agent: AgentRecord, chain: WrfcChainLike): AgentRecord['wrfcRole'] {
|
|
411
|
-
if (agent.wrfcRole) return agent.wrfcRole;
|
|
412
|
-
if (chain.ownerAgentId === agent.id) return 'owner';
|
|
413
|
-
if (chain.engineerAgentId === agent.id) return 'engineer';
|
|
414
|
-
if (chain.reviewerAgentId === agent.id) return 'reviewer';
|
|
415
|
-
if (chain.fixerAgentId === agent.id) return 'fixer';
|
|
416
|
-
if (agent.template === 'reviewer') return 'reviewer';
|
|
417
|
-
return 'engineer';
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function getAgentGroupKey(agent: AgentRecord, activeById: Map<string, AgentRecord>): string {
|
|
421
|
-
if (agent.wrfcId) return `wrfc:${agent.wrfcId}`;
|
|
422
|
-
|
|
423
|
-
const seen = new Set<string>();
|
|
424
|
-
let root = agent;
|
|
425
|
-
while (root.parentAgentId && activeById.has(root.parentAgentId) && !seen.has(root.parentAgentId)) {
|
|
426
|
-
seen.add(root.id);
|
|
427
|
-
root = activeById.get(root.parentAgentId)!;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// If the active root is an orphaned child, keep it anchored to its missing parent id
|
|
431
|
-
// so it does not jump to a new group when the parent exits before its children.
|
|
432
|
-
return `root:${root.parentAgentId ?? root.id}`;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function safeGetChain(wrfcId: string, deps: Pick<ProcessModalDeps, 'wrfcController'>): WrfcChainLike | null {
|
|
436
|
-
try {
|
|
437
|
-
const chain = deps.wrfcController.getChain(wrfcId);
|
|
438
|
-
return isWrfcChainLike(chain) ? chain : null;
|
|
439
|
-
} catch {
|
|
440
|
-
return null;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** Get the original task description from a WRFC chain. */
|
|
445
|
-
function getChainTask(wrfcId: string | undefined, deps: Pick<ProcessModalDeps, 'wrfcController'>): string | null {
|
|
446
|
-
if (!wrfcId) return null;
|
|
447
|
-
return safeGetChain(wrfcId, deps)?.task ?? null;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** Truncate to first line, capped at max chars. */
|
|
451
|
-
function truncateFirst(text: string, max: number): string {
|
|
452
|
-
const line = text.split('\n')[0].trim();
|
|
453
|
-
return line.length > max ? line.slice(0, Math.max(0, max - 3)) + '...' : line;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/** Truncate a command string to first line, capped at MAX_LABEL_LENGTH. */
|
|
457
23
|
function truncateCmd(text: string): string {
|
|
458
|
-
const firstLine = text.split('\n')[0]
|
|
459
|
-
if (firstLine.length > MAX_LABEL_LENGTH) return firstLine.slice(0, MAX_LABEL_LENGTH - 3)
|
|
24
|
+
const firstLine = text.split('\n')[0]?.trim() ?? '';
|
|
25
|
+
if (firstLine.length > MAX_LABEL_LENGTH) return `${firstLine.slice(0, MAX_LABEL_LENGTH - 3)}...`;
|
|
460
26
|
return firstLine;
|
|
461
27
|
}
|
|
462
28
|
|
|
463
|
-
// ─── ProcessModalState ────────────────────────────────────────────────────────
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* ProcessModal — manages the state for the background-process list modal.
|
|
467
|
-
*
|
|
468
|
-
* Holds the list of ProcessEntry items, selected index, and active flag.
|
|
469
|
-
* Rendering is done by renderProcessModal().
|
|
470
|
-
*/
|
|
471
29
|
export class ProcessModal {
|
|
472
30
|
public active = false;
|
|
473
31
|
public selectedIndex = 0;
|
|
474
32
|
public entries: ProcessEntry[] = [];
|
|
475
33
|
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
476
34
|
private onRefresh: (() => void) | null = null;
|
|
477
|
-
private groupOrder = new Map<string, number>();
|
|
478
|
-
private nextGroupOrder = 0;
|
|
479
35
|
|
|
480
36
|
constructor(private readonly deps: ProcessModalDeps) {}
|
|
481
37
|
|
|
482
|
-
/** Set a callback to trigger re-render on timer tick. */
|
|
483
38
|
setOnRefresh(fn: () => void): void {
|
|
484
39
|
this.onRefresh = fn;
|
|
485
40
|
}
|
|
@@ -503,55 +58,28 @@ export class ProcessModal {
|
|
|
503
58
|
}
|
|
504
59
|
}
|
|
505
60
|
|
|
506
|
-
/** Rebuild entries from the currently owned runtime services. */
|
|
507
61
|
refresh(): void {
|
|
508
|
-
const manager = this.deps.agentManager;
|
|
509
|
-
if (typeof manager?.list !== 'function') return; // Guard against test mock pollution
|
|
510
62
|
const now = Date.now();
|
|
511
63
|
const result: ProcessEntry[] = [];
|
|
512
64
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
result.push(...buildAgentEntries(
|
|
517
|
-
manager.list(),
|
|
518
|
-
this.deps,
|
|
519
|
-
now,
|
|
520
|
-
(key) => this.groupOrder.get(key),
|
|
521
|
-
(key) => this.ensureGroupOrder(key),
|
|
522
|
-
));
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Background exec processes — only show running
|
|
526
|
-
const pm = this.deps.processManager;
|
|
527
|
-
for (const p of pm.list()) {
|
|
528
|
-
if (p.status.startsWith('done')) continue;
|
|
529
|
-
const startTime = pm.getStatus(p.id)?.startTime ?? now;
|
|
65
|
+
for (const process of this.deps.processManager.list()) {
|
|
66
|
+
if (process.status.startsWith('done')) continue;
|
|
67
|
+
const startTime = this.deps.processManager.getStatus(process.id)?.startTime ?? now;
|
|
530
68
|
result.push({
|
|
531
|
-
id:
|
|
532
|
-
label: truncateCmd(
|
|
69
|
+
id: process.id,
|
|
70
|
+
label: truncateCmd(process.cmd),
|
|
533
71
|
type: 'exec',
|
|
534
|
-
status:
|
|
72
|
+
status: process.status,
|
|
535
73
|
elapsedMs: now - startTime,
|
|
536
74
|
});
|
|
537
75
|
}
|
|
538
76
|
|
|
539
77
|
this.entries = result;
|
|
540
|
-
|
|
541
|
-
// Keep selection in-bounds
|
|
542
78
|
if (this.selectedIndex >= this.entries.length) {
|
|
543
79
|
this.selectedIndex = Math.max(0, this.entries.length - 1);
|
|
544
80
|
}
|
|
545
81
|
}
|
|
546
82
|
|
|
547
|
-
private ensureGroupOrder(key: string): number {
|
|
548
|
-
const existing = this.groupOrder.get(key);
|
|
549
|
-
if (existing !== undefined) return existing;
|
|
550
|
-
const next = this.nextGroupOrder++;
|
|
551
|
-
this.groupOrder.set(key, next);
|
|
552
|
-
return next;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
83
|
moveUp(): void {
|
|
556
84
|
if (this.entries.length === 0) return;
|
|
557
85
|
this.selectedIndex = (this.selectedIndex - 1 + this.entries.length) % this.entries.length;
|
|
@@ -566,30 +94,13 @@ export class ProcessModal {
|
|
|
566
94
|
return this.entries[this.selectedIndex];
|
|
567
95
|
}
|
|
568
96
|
|
|
569
|
-
|
|
570
|
-
* Stop the selected shell process.
|
|
571
|
-
* Agent entries are read-only in GoodVibes Agent; build execution and
|
|
572
|
-
* cancellation belong to GoodVibes TUI/shared-session owners.
|
|
573
|
-
*/
|
|
574
|
-
killSelected(): boolean {
|
|
97
|
+
stopSelected(): boolean {
|
|
575
98
|
const entry = this.getSelected();
|
|
576
99
|
if (!entry) return false;
|
|
577
|
-
|
|
578
|
-
if (entry.type === 'exec') {
|
|
579
|
-
return this.deps.processManager.stop(entry.id);
|
|
580
|
-
}
|
|
581
|
-
return false;
|
|
100
|
+
return this.deps.processManager.stop(entry.id);
|
|
582
101
|
}
|
|
583
102
|
}
|
|
584
103
|
|
|
585
|
-
// ─── renderProcessModal ───────────────────────────────────────────────────────
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Render the process list modal as Line[] for overlay in the viewport.
|
|
589
|
-
*
|
|
590
|
-
* @param modal ProcessModal state
|
|
591
|
-
* @param width Terminal width
|
|
592
|
-
*/
|
|
593
104
|
export function renderProcessModal(modal: ProcessModal, width: number, viewportHeight = 24): Line[] {
|
|
594
105
|
modal.refresh();
|
|
595
106
|
|
|
@@ -612,7 +123,7 @@ export function renderProcessModal(modal: ProcessModal, width: number, viewportH
|
|
|
612
123
|
margin: boxMargin,
|
|
613
124
|
targetContentRows,
|
|
614
125
|
sections: [
|
|
615
|
-
{ type: 'text', content: 'No
|
|
126
|
+
{ type: 'text', content: 'No running shell processes.' },
|
|
616
127
|
],
|
|
617
128
|
hints: ['[Esc] Close'],
|
|
618
129
|
}, width);
|
|
@@ -622,34 +133,21 @@ export function renderProcessModal(modal: ProcessModal, width: number, viewportH
|
|
|
622
133
|
const window = getVisibleWindow(modal.entries.length, modal.selectedIndex, maxVisibleRows);
|
|
623
134
|
const visibleEntries = modal.entries.slice(window.start, window.end);
|
|
624
135
|
|
|
625
|
-
const items = visibleEntries.map((
|
|
626
|
-
const absoluteIndex = window.start +
|
|
627
|
-
const statusIcon =
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}[e.status] ?? '•';
|
|
634
|
-
const typeTag = e.type === 'agent' ? '[agent]' : '[exec]';
|
|
635
|
-
const dur = formatDuration(e.elapsedMs);
|
|
636
|
-
const statusStr = e.streamSnippet ? `streaming ${dur}` : `${e.status} ${dur}`;
|
|
637
|
-
const suffix = ` ${statusStr}`;
|
|
638
|
-
const treePrefix = e.treePrefix ?? '';
|
|
639
|
-
const maxDescW = maxLabelW - typeTag.length - treePrefix.length - suffix.length - 4; // icon + spaces
|
|
640
|
-
const desc = e.label.length > maxDescW ? e.label.slice(0, Math.max(0, maxDescW - 3)) + '...' : e.label;
|
|
641
|
-
const label = `${statusIcon} ${typeTag} ${treePrefix}${desc}${suffix}`;
|
|
136
|
+
const items = visibleEntries.map((entry, index) => {
|
|
137
|
+
const absoluteIndex = window.start + index;
|
|
138
|
+
const statusIcon = entry.status === 'running' ? '*' : entry.status === 'failed' ? '!' : '-';
|
|
139
|
+
const dur = formatDuration(entry.elapsedMs);
|
|
140
|
+
const suffix = ` ${entry.status} ${dur}`;
|
|
141
|
+
const typeTag = '[exec]';
|
|
142
|
+
const maxDescW = Math.max(0, maxLabelW - typeTag.length - suffix.length - 4);
|
|
143
|
+
const desc = entry.label.length > maxDescW ? `${entry.label.slice(0, Math.max(0, maxDescW - 3))}...` : entry.label;
|
|
642
144
|
return {
|
|
643
|
-
label
|
|
145
|
+
label: `${statusIcon} ${typeTag} ${desc}${suffix}`,
|
|
644
146
|
selected: absoluteIndex === modal.selectedIndex,
|
|
645
147
|
};
|
|
646
148
|
});
|
|
647
|
-
const sections: import('./modal-factory.ts').ModalSection[] = [
|
|
648
|
-
|
|
649
|
-
];
|
|
650
|
-
if (modal.entries.length > maxVisibleRows) {
|
|
651
|
-
sections.push({ type: 'separator' });
|
|
652
|
-
}
|
|
149
|
+
const sections: import('./modal-factory.ts').ModalSection[] = [{ type: 'list', items }];
|
|
150
|
+
if (modal.entries.length > maxVisibleRows) sections.push({ type: 'separator' });
|
|
653
151
|
|
|
654
152
|
return ModalFactory.createModal({
|
|
655
153
|
title: 'Runtime Activity',
|
|
@@ -660,6 +158,6 @@ export function renderProcessModal(modal: ProcessModal, width: number, viewportH
|
|
|
660
158
|
helpers: modal.entries.length > maxVisibleRows
|
|
661
159
|
? [{ content: `[${window.start + 1}-${window.end} of ${modal.entries.length}]` }]
|
|
662
160
|
: undefined,
|
|
663
|
-
hints: ['[Up/Down] Navigate', '[Enter]
|
|
161
|
+
hints: ['[Up/Down] Navigate', '[Enter] Output', '[k] Stop process', '[Esc] Close'],
|
|
664
162
|
}, width);
|
|
665
163
|
}
|