pi-crew 0.9.8 → 0.9.10
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 +311 -0
- package/README.md +2 -2
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/extension/register.ts +94 -21
- package/src/extension/registration/subagent-helpers.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +9 -0
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/batch-barrier.ts +145 -0
- package/src/runtime/child-pi.ts +135 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/crash-classification.ts +208 -0
- package/src/runtime/custom-tools/irc-tool.ts +47 -7
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-agent-manager.ts +185 -0
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/process-lifecycle.ts +481 -0
- package/src/runtime/role-permission.ts +2 -2
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/subagent-manager.ts +6 -0
- package/src/runtime/task-output-context.ts +209 -24
- package/src/runtime/task-runner.ts +76 -15
- package/src/runtime/tool-output-pruner.ts +334 -0
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/state/types.ts +5 -0
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +17 -1
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
package/src/ui/crew-widget.ts
DELETED
|
@@ -1,544 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { CrewUiConfig } from "../config/config.ts";
|
|
3
|
-
import { listRecentRuns } from "../extension/run-index.ts";
|
|
4
|
-
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
|
-
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
6
|
-
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
7
|
-
import { listLiveAgents, evictStaleLiveAgentHandles, type LiveAgentHandle } from "../runtime/live-agent-manager.ts";
|
|
8
|
-
import { getTaskUsage } from "../runtime/usage-tracker.ts";
|
|
9
|
-
import type { TeamRunManifest } from "../state/types.ts";
|
|
10
|
-
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
11
|
-
import { reconcileAllStaleRuns } from "../runtime/crash-recovery.ts";
|
|
12
|
-
import { iconForStatus } from "./status-colors.ts";
|
|
13
|
-
import { truncate } from "../utils/visual.ts";
|
|
14
|
-
import type { CrewTheme } from "./theme-adapter.ts";
|
|
15
|
-
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
16
|
-
import { Box, Text } from "./layout-primitives.ts";
|
|
17
|
-
import { requestRender, setExtensionWidget } from "./pi-ui-compat.ts";
|
|
18
|
-
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
19
|
-
import { runEventBus } from "./run-event-bus.ts";
|
|
20
|
-
import { DEFAULT_UI } from "../config/defaults.ts";
|
|
21
|
-
import { computePhaseProgress, formatPhaseProgressLine } from "../runtime/phase-progress.ts";
|
|
22
|
-
import { SUBAGENT_SPINNER_FRAMES, spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
23
|
-
|
|
24
|
-
const SPINNER = SUBAGENT_SPINNER_FRAMES;
|
|
25
|
-
const TOOL_LABELS: Record<string, string> = {
|
|
26
|
-
head: "reading",
|
|
27
|
-
bash: "running command",
|
|
28
|
-
edit: "editing",
|
|
29
|
-
write: "writing",
|
|
30
|
-
grep: "searching",
|
|
31
|
-
find: "finding files",
|
|
32
|
-
ls: "listing",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const TOOL_ICONS: Record<string, string> = {
|
|
36
|
-
read: "📖",
|
|
37
|
-
bash: ">",
|
|
38
|
-
edit: "✏",
|
|
39
|
-
write: "📝",
|
|
40
|
-
grep: "🔍",
|
|
41
|
-
find: "📁",
|
|
42
|
-
ls: "📋",
|
|
43
|
-
agent: "🤖",
|
|
44
|
-
};
|
|
45
|
-
const LEGACY_WIDGET_KEY = "pi-crew";
|
|
46
|
-
const WIDGET_KEY = "pi-crew-active";
|
|
47
|
-
const STATUS_KEY = "pi-crew";
|
|
48
|
-
|
|
49
|
-
const MAX_LINES_DEFAULT = DEFAULT_UI.widgetMaxLines;
|
|
50
|
-
const MAX_AGENTS_DISPLAY = 3;
|
|
51
|
-
/** R1: How many turns finished agents linger before disappearing. */
|
|
52
|
-
const FINISHED_LINGER_MAX_AGE = 1;
|
|
53
|
-
const ERROR_LINGER_MAX_AGE = 2;
|
|
54
|
-
const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
|
|
55
|
-
/** R3: Faster refresh when live agents are running. Aligned with spinner frame. */
|
|
56
|
-
const LIVE_REFRESH_MS = 160;
|
|
57
|
-
|
|
58
|
-
type WidgetComponent = { render(width: number): string[]; invalidate(): void };
|
|
59
|
-
|
|
60
|
-
interface CrewWidgetModel {
|
|
61
|
-
cwd: string;
|
|
62
|
-
frame: number;
|
|
63
|
-
maxLines: number;
|
|
64
|
-
notificationCount?: number;
|
|
65
|
-
manifestCache?: ManifestCache;
|
|
66
|
-
snapshotCache?: RunSnapshotCache;
|
|
67
|
-
preloadManifests?: TeamRunManifest[];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface CrewWidgetState {
|
|
71
|
-
frame: number;
|
|
72
|
-
interval?: ReturnType<typeof setInterval>;
|
|
73
|
-
lastPlacement?: string;
|
|
74
|
-
lastVisibility?: "hidden" | "visible";
|
|
75
|
-
lastKey?: string;
|
|
76
|
-
lastMaxLines?: number;
|
|
77
|
-
lastCwd?: string;
|
|
78
|
-
legacyCleared?: boolean;
|
|
79
|
-
model?: CrewWidgetModel;
|
|
80
|
-
notificationCount?: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface WidgetRun {
|
|
84
|
-
run: TeamRunManifest;
|
|
85
|
-
agents: CrewAgentRecord[];
|
|
86
|
-
snapshot?: RunUiSnapshot;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
|
|
90
|
-
if (!iso) return undefined;
|
|
91
|
-
const ms = Math.max(0, now - new Date(iso).getTime());
|
|
92
|
-
if (!Number.isFinite(ms)) return undefined;
|
|
93
|
-
if (ms < 1000) return "now";
|
|
94
|
-
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
95
|
-
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
96
|
-
return `${Math.floor(ms / 3_600_000)}h`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function describeLiveActivity(handle: LiveAgentHandle): string {
|
|
100
|
-
const act = handle.activity;
|
|
101
|
-
if (act.activeTools.size > 0) {
|
|
102
|
-
const groups = new Map<string, number>();
|
|
103
|
-
for (const toolName of act.activeTools.values()) {
|
|
104
|
-
groups.set(toolName, (groups.get(toolName) ?? 0) + 1);
|
|
105
|
-
}
|
|
106
|
-
const parts: string[] = [];
|
|
107
|
-
for (const [toolName, count] of groups) {
|
|
108
|
-
const icon = TOOL_ICONS[toolName] ?? "?";
|
|
109
|
-
const label = TOOL_LABELS[toolName] ?? toolName;
|
|
110
|
-
if (count > 1) {
|
|
111
|
-
parts.push(`${icon}${count} ${label}s`);
|
|
112
|
-
} else {
|
|
113
|
-
parts.push(`${icon} ${label}`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return parts.join(", ") + "…";
|
|
117
|
-
}
|
|
118
|
-
if (act.responseText?.trim()) {
|
|
119
|
-
const line = act.responseText.split("\n").find((l) => l.trim())?.trim() ?? "";
|
|
120
|
-
return line.length > 60 ? line.slice(0, 60) + "…" : line;
|
|
121
|
-
}
|
|
122
|
-
return "thinking…";
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function agentActivity(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle): string {
|
|
126
|
-
if (liveHandle && liveHandle.status === "running") {
|
|
127
|
-
const live = describeLiveActivity(liveHandle);
|
|
128
|
-
// If live activity is just the fallback, prefer richer agent.progress data
|
|
129
|
-
// (persistLiveProgress writes tool events to agentProgress before live tracking picks them up)
|
|
130
|
-
if (live === "thinking…" && agent.progress?.currentTool) return `${TOOL_LABELS[agent.progress.currentTool] ?? agent.progress.currentTool}…`;
|
|
131
|
-
return live;
|
|
132
|
-
}
|
|
133
|
-
if (agent.progress?.currentTool) return `${TOOL_LABELS[agent.progress.currentTool] ?? agent.progress.currentTool}…`;
|
|
134
|
-
const recent = agent.progress?.recentOutput?.at(-1);
|
|
135
|
-
if (recent) {
|
|
136
|
-
const cleaned = recent.replace(/\s+/g, " ").trim();
|
|
137
|
-
return cleaned.length > 60 ? cleaned.slice(0, 60) + "…" : cleaned;
|
|
138
|
-
}
|
|
139
|
-
if (agent.progress?.activityState === "needs_attention") return "needs attention";
|
|
140
|
-
if (agent.status === "queued") return "queued";
|
|
141
|
-
if (agent.status === "running") {
|
|
142
|
-
const age = agent.startedAt ? Date.now() - new Date(agent.startedAt).getTime() : Infinity;
|
|
143
|
-
if (age < 5000 && !agent.progress?.currentTool) return "spawning…";
|
|
144
|
-
return "thinking…";
|
|
145
|
-
}
|
|
146
|
-
if (agent.status === "failed") return agent.error ?? "failed";
|
|
147
|
-
return "done";
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function formatTokensCompact(count: number): string {
|
|
151
|
-
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tok`;
|
|
152
|
-
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tok`;
|
|
153
|
-
return `${count} tok`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle): string {
|
|
157
|
-
const parts: string[] = [];
|
|
158
|
-
if (liveHandle) {
|
|
159
|
-
const act = liveHandle.activity;
|
|
160
|
-
if (act.toolUses > 0) parts.push(`${act.toolUses} tools`);
|
|
161
|
-
const usage = getTaskUsage(liveHandle.taskId);
|
|
162
|
-
const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
|
|
163
|
-
if (total > 0) parts.push(formatTokensCompact(total));
|
|
164
|
-
try {
|
|
165
|
-
const stats = liveHandle.session.getSessionStats?.();
|
|
166
|
-
const ctxPct = stats?.contextUsage?.percent;
|
|
167
|
-
if (ctxPct != null) parts.push(`${Math.round(ctxPct)}% ctx`);
|
|
168
|
-
} catch { /* ignore */ }
|
|
169
|
-
const rawStarted = act.startedAtMs || 0;
|
|
170
|
-
const rawCompleted = act.completedAtMs || 0;
|
|
171
|
-
const nowMs = Date.now();
|
|
172
|
-
const nowSec = Math.floor(nowMs / 1000);
|
|
173
|
-
// Detect if value is in seconds vs milliseconds by comparing distance to current time
|
|
174
|
-
// If value is closer to nowSec (within ±2 years) AND not close to nowMs, treat as seconds
|
|
175
|
-
const isSeconds = (v: number) => {
|
|
176
|
-
if (v <= 0) return false;
|
|
177
|
-
const distToSec = Math.abs(v - nowSec);
|
|
178
|
-
const distToMs = Math.abs(v - nowMs);
|
|
179
|
-
// If distance to seconds is much smaller AND value is in valid seconds range
|
|
180
|
-
return distToSec < distToMs && distToSec < 31536000 * 2 && v > 1000000000;
|
|
181
|
-
};
|
|
182
|
-
// Simple fix: detect if value is Unix seconds and convert properly
|
|
183
|
-
// Unix seconds are ~10 digits (e.g., 1779082445), Unix ms are ~13 digits (e.g., 1779082445000)
|
|
184
|
-
const toMs = (v: number): number => {
|
|
185
|
-
if (v <= 0) return 0;
|
|
186
|
-
// If 10 digits (or 9 with recent), treat as seconds
|
|
187
|
-
if (v > 1000000000 && v < 10000000000) return v * 1000;
|
|
188
|
-
// If 13 digits, treat as ms
|
|
189
|
-
if (v > 100000000000 && v < 10000000000000) return v;
|
|
190
|
-
// Fallback: use as-is
|
|
191
|
-
return v;
|
|
192
|
-
};
|
|
193
|
-
const startedMs = toMs(rawStarted);
|
|
194
|
-
const completedMs = rawCompleted > 0 ? toMs(rawCompleted) : 0;
|
|
195
|
-
// Validate bounds
|
|
196
|
-
const isValidStarted = startedMs > 0 && startedMs < nowMs + 60000 && startedMs > nowMs - 3155692600000;
|
|
197
|
-
const isValidCompleted = completedMs === 0 || (completedMs > 0 && completedMs < nowMs + 60000);
|
|
198
|
-
const ms = (isValidCompleted ? completedMs : nowMs) - (isValidStarted ? startedMs : nowMs);
|
|
199
|
-
parts.push(`${(ms / 1000).toFixed(1)}s`);
|
|
200
|
-
} else {
|
|
201
|
-
if (agent.toolUses) parts.push(`${agent.toolUses} tools`);
|
|
202
|
-
if (agent.progress?.tokens) parts.push(formatTokensCompact(agent.progress.tokens));
|
|
203
|
-
const age = elapsed(agent.completedAt ?? agent.startedAt);
|
|
204
|
-
if (age) parts.push(age);
|
|
205
|
-
}
|
|
206
|
-
return parts.join(" · ");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
|
|
210
|
-
try {
|
|
211
|
-
return readCrewAgents(run);
|
|
212
|
-
} catch {
|
|
213
|
-
return [];
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Timestamp of the last periodic stale-reconciliation. Throttled to 60s to avoid excessive disk I/O. */
|
|
218
|
-
let lastStaleReconcileAt = 0;
|
|
219
|
-
const STALE_RECONCILE_INTERVAL_MS = 60_000;
|
|
220
|
-
|
|
221
|
-
export function activeWidgetRuns(cwd: string, manifestCache?: ManifestCache, snapshotCache?: RunSnapshotCache, preloadedManifests?: TeamRunManifest[], workspaceId?: string): WidgetRun[] {
|
|
222
|
-
// Evict stale live-agent handles (terminal status >10min, or running >30min with no update)
|
|
223
|
-
evictStaleLiveAgentHandles();
|
|
224
|
-
// Periodic stale reconciliation: detect ghost runs on disk with dead PIDs
|
|
225
|
-
// and repair them to terminal status. Throttled to once per 60 seconds.
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
if (now - lastStaleReconcileAt > STALE_RECONCILE_INTERVAL_MS && manifestCache) {
|
|
228
|
-
lastStaleReconcileAt = now;
|
|
229
|
-
try { reconcileAllStaleRuns(cwd, manifestCache); } catch { /* non-critical background maintenance */ }
|
|
230
|
-
}
|
|
231
|
-
let runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
|
|
232
|
-
// Filter by workspaceId for session isolation
|
|
233
|
-
if (workspaceId) {
|
|
234
|
-
runs = runs.filter((run) => !run.ownerSessionId || run.ownerSessionId === workspaceId);
|
|
235
|
-
}
|
|
236
|
-
return runs
|
|
237
|
-
.map((run) => {
|
|
238
|
-
try {
|
|
239
|
-
// 1.2: render path is read-only. Use cache.get() only; if miss
|
|
240
|
-
// the background preload loop will populate on the next tick.
|
|
241
|
-
// Fall back to manifest-only data so the widget still renders
|
|
242
|
-
// without blocking on disk.
|
|
243
|
-
const snapshot = snapshotCache?.get(run.runId);
|
|
244
|
-
return snapshot ? { run: snapshot.manifest, agents: snapshot.agents, snapshot } : { run, agents: agentsFor(run) };
|
|
245
|
-
} catch {
|
|
246
|
-
return { run, agents: agentsFor(run) };
|
|
247
|
-
}
|
|
248
|
-
})
|
|
249
|
-
.filter((item) => isDisplayActiveRun(item.run, item.agents));
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function statusSummary(runs: WidgetRun[]): string {
|
|
253
|
-
const agents = runs.flatMap((item) => item.agents);
|
|
254
|
-
const runningAgents = agents.filter((agent) => agent.status === "running").length;
|
|
255
|
-
const queuedAgents = agents.filter((agent) => agent.status === "queued" || agent.status === "waiting").length;
|
|
256
|
-
const completedAgents = agents.filter((agent) => agent.status === "completed").length;
|
|
257
|
-
const totalAgents = agents.length;
|
|
258
|
-
const totalRuns = runs.length;
|
|
259
|
-
const model = agents.find((a) => a.model)?.model?.split("/").at(-1);
|
|
260
|
-
const parts = [`⚙ ${runningAgents}r`];
|
|
261
|
-
if (queuedAgents > 0) parts.push(`${queuedAgents}q`);
|
|
262
|
-
if (completedAgents > 0) parts.push(`${completedAgents}/${totalAgents}done`);
|
|
263
|
-
if (totalRuns > 1) parts.push(`${totalRuns}runs`);
|
|
264
|
-
if (model) parts.push(model);
|
|
265
|
-
return parts.join(" · ");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
export function notificationBadge(count: number | undefined, env: NodeJS.ProcessEnv = process.env): string {
|
|
269
|
-
if (!count || count <= 0) return "";
|
|
270
|
-
const term = `${env.TERM ?? ""} ${env.WT_SESSION ?? ""} ${env.TERM_PROGRAM ?? ""}`.toLowerCase();
|
|
271
|
-
const supportsEmoji = !term.includes("dumb") && env.NO_COLOR !== "1";
|
|
272
|
-
return supportsEmoji ? ` 🔔${count}` : ` [!${count}]`;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines = 20, notificationCount = 0): string {
|
|
276
|
-
const agents = runs.flatMap((item) => item.agents);
|
|
277
|
-
const runningAgents = agents.filter((agent) => agent.status === "running").length;
|
|
278
|
-
const queuedAgents = agents.filter((agent) => agent.status === "queued").length;
|
|
279
|
-
const waitingAgents = agents.filter((agent) => agent.status === "waiting").length;
|
|
280
|
-
const completedAgents = agents.filter((agent) => agent.status === "completed").length;
|
|
281
|
-
const parts = [`${runningAgents} running`];
|
|
282
|
-
if (queuedAgents) parts.push(`${queuedAgents} queued`);
|
|
283
|
-
if (waitingAgents) parts.push(`${waitingAgents} waiting`);
|
|
284
|
-
if (completedAgents) parts.push(`${completedAgents}/${agents.length} done`);
|
|
285
|
-
return `${runningGlyph} Crew agents${notificationBadge(notificationCount)} · ${parts.join(" · ")} · /team-dashboard`;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function shortRunLabel(run: TeamRunManifest): string {
|
|
289
|
-
return `${run.team}/${run.workflow ?? "none"}`;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0): string[] {
|
|
293
|
-
const runs = providedRuns ?? activeWidgetRuns(cwd);
|
|
294
|
-
if (!runs.length) return [];
|
|
295
|
-
// Time-based spinner glyph so animation stays smooth at the renderer's
|
|
296
|
-
// natural cadence — independent of how often `frame` counter advances.
|
|
297
|
-
const runningGlyph = spinnerFrame("widget-header");
|
|
298
|
-
const lines: string[] = [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
|
|
299
|
-
for (const { run, agents, snapshot } of runs) {
|
|
300
|
-
const activeAgents = agents.filter((item) => item.status === "running" || item.status === "queued" || item.status === "waiting");
|
|
301
|
-
// R1: Include recently finished agents (linger 1-2 min)
|
|
302
|
-
const now = Date.now();
|
|
303
|
-
const finishedAgents = agents.filter((item) => {
|
|
304
|
-
if (item.status === "running" || item.status === "queued" || item.status === "waiting") return false;
|
|
305
|
-
if (!item.completedAt) return false;
|
|
306
|
-
const maxAgeMs = (ERROR_STATUSES.has(item.status) ? ERROR_LINGER_MAX_AGE : FINISHED_LINGER_MAX_AGE) * 60_000;
|
|
307
|
-
const age = now - new Date(item.completedAt).getTime();
|
|
308
|
-
return Number.isFinite(age) && age < maxAgeMs;
|
|
309
|
-
});
|
|
310
|
-
const completed = agents.filter((agent) => agent.status === "completed").length;
|
|
311
|
-
const runGlyph = iconForStatus(run.status, { runningGlyph });
|
|
312
|
-
const phaseLine = snapshot ? formatPhaseProgressLine(computePhaseProgress(snapshot.tasks)) : "";
|
|
313
|
-
const progressPart = phaseLine ? `${phaseLine}` : `${completed}/${agents.length} done`;
|
|
314
|
-
lines.push(`\u251C\u2500 ${runGlyph} ${shortRunLabel(run)} \u00B7 ${progressPart} \u00B7 ${run.runId.slice(-8)}`);
|
|
315
|
-
const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
|
|
316
|
-
// Render finished agents first (compact 1-line format)
|
|
317
|
-
for (const agent of finishedAgents.slice(0, 2)) {
|
|
318
|
-
const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
|
|
319
|
-
const name = liveHandle?.agent ?? agent.agent;
|
|
320
|
-
const icon = agent.status === "completed" ? "\u2713" : agent.status === "failed" ? "\u2717" : agent.status === "needs_attention" ? "\u26A0" : "\u25AA";
|
|
321
|
-
const stats = agentStats(agent, liveHandle);
|
|
322
|
-
const desc = liveHandle?.description ?? agent.role;
|
|
323
|
-
lines.push(`\u2502 \u251C\u2500 ${icon} ${name} \u00B7 ${desc}${stats ? ` \u00B7 ${stats}` : ""}`);
|
|
324
|
-
}
|
|
325
|
-
// Render active agents
|
|
326
|
-
const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
|
|
327
|
-
for (const [index, agent] of visibleAgents.entries()) {
|
|
328
|
-
const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY;
|
|
329
|
-
const branch = last ? "\u2514\u2500" : "\u251C\u2500";
|
|
330
|
-
const agentGlyph = iconForStatus(agent.status, { runningGlyph });
|
|
331
|
-
const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
|
|
332
|
-
const stats = agentStats(agent, liveHandle);
|
|
333
|
-
const name = liveHandle?.agent ?? agent.agent;
|
|
334
|
-
const desc = liveHandle?.description ?? agent.role;
|
|
335
|
-
lines.push(`\u2502 ${branch} ${agentGlyph} ${name}${desc ? ` \u00B7 ${desc}` : ` \u00B7 ${agent.role}`}`);
|
|
336
|
-
lines.push(`\u2502 \u23B7 ${agentActivity(agent, liveHandle)}${stats ? ` \u00B7 ${stats}` : ""}`);
|
|
337
|
-
}
|
|
338
|
-
if (activeAgents.length > MAX_AGENTS_DISPLAY) lines.push(`\u2502 \u2514\u2500 \u2026 +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`);
|
|
339
|
-
if (lines.length >= maxLines) break;
|
|
340
|
-
}
|
|
341
|
-
return lines.slice(0, maxLines);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function statusGlyphColor(icon: string): Parameters<CrewTheme["fg"]>[0] {
|
|
345
|
-
const mapping: Record<string, Parameters<CrewTheme["fg"]>[0]> = {
|
|
346
|
-
"✓": "success",
|
|
347
|
-
"✗": "error",
|
|
348
|
-
"■": "warning",
|
|
349
|
-
"⏸": "warning",
|
|
350
|
-
"◦": "dim",
|
|
351
|
-
"·": "dim",
|
|
352
|
-
"▶": "accent",
|
|
353
|
-
};
|
|
354
|
-
return mapping[icon] ?? "accent";
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function colorWidgetLine(line: string, index: number, theme: CrewTheme): string {
|
|
358
|
-
let result = line;
|
|
359
|
-
if (index === 0) {
|
|
360
|
-
result = result.replace("Crew agents", theme.bold(theme.fg("accent", "Crew agents")));
|
|
361
|
-
}
|
|
362
|
-
result = result.replace(/[✓✗■⏸◦·▶]/g, (icon) => theme.fg(statusGlyphColor(icon), icon));
|
|
363
|
-
if (index === 0) {
|
|
364
|
-
result = theme.fg("accent", result);
|
|
365
|
-
}
|
|
366
|
-
return result;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function renderLines(lines: string[], width: number): string[] {
|
|
370
|
-
const box = new Box(0, 0);
|
|
371
|
-
for (const line of lines) {
|
|
372
|
-
box.addChild(new Text(line));
|
|
373
|
-
}
|
|
374
|
-
return box.render(width);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
class CrewWidgetComponent implements WidgetComponent {
|
|
378
|
-
private readonly model: CrewWidgetModel;
|
|
379
|
-
private theme: CrewTheme;
|
|
380
|
-
private cacheSignature: string;
|
|
381
|
-
private cachedWidth = 0;
|
|
382
|
-
private cachedLines: string[] = [];
|
|
383
|
-
private cachedBaseLines: string[] = [];
|
|
384
|
-
private cachedTheme: CrewTheme;
|
|
385
|
-
private readonly unsubscribeTheme: () => void;
|
|
386
|
-
private readonly unsubscribeEventBus: () => void;
|
|
387
|
-
|
|
388
|
-
constructor(model: CrewWidgetModel, themeLike: unknown) {
|
|
389
|
-
this.model = model;
|
|
390
|
-
this.theme = asCrewTheme(themeLike);
|
|
391
|
-
this.cachedTheme = this.theme;
|
|
392
|
-
this.cacheSignature = "";
|
|
393
|
-
this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
|
|
394
|
-
this.unsubscribeEventBus = runEventBus.onAny(() => this.invalidate());
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
private buildSignature(runs: WidgetRun[]): string {
|
|
398
|
-
const liveSig = listLiveAgents().map((h) => `${h.agentId}:${h.status}:${h.activity.turnCount}:${h.activity.toolUses}:${[...h.activity.activeTools.values()].join(",")}:${h.activity.responseText.slice(-30)}`).join("|");
|
|
399
|
-
// When any agent is running we want per-agent runningGlyph (baked into
|
|
400
|
-
// cachedBaseLines) to re-rotate in step with the spinner bucket. Without
|
|
401
|
-
// this, finished+running rows would freeze between data updates.
|
|
402
|
-
const hasRunning = runs.some((entry) => entry.agents.some((agent) => agent.status === "running"))
|
|
403
|
-
|| listLiveAgents().some((h) => h.status === "running");
|
|
404
|
-
const animation = hasRunning ? `:spin=${spinnerBucket()}` : "";
|
|
405
|
-
return runs
|
|
406
|
-
.map((entry) => entry.snapshot?.signature ?? `${entry.run.runId}:${entry.run.status}:${entry.run.updatedAt}:` + entry.agents.map((agent) => {
|
|
407
|
-
const recentOutput = agent.progress?.recentOutput.at(-1) ?? "";
|
|
408
|
-
const progress = [agent.progress?.currentTool ?? "", agent.progress?.toolCount ?? 0, agent.progress?.tokens ?? 0, agent.progress?.turns ?? 0, agent.progress?.lastActivityAt ?? "", recentOutput].join(":");
|
|
409
|
-
return `${agent.status}:${agent.startedAt}:${agent.completedAt ?? ""}:${agent.toolUses ?? 0}:${progress}`;
|
|
410
|
-
}).join(","))
|
|
411
|
-
.join("|") + `|live:${liveSig}${animation}`;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
private colorize(lines: string[], width: number): string[] {
|
|
415
|
-
return renderLines(lines.map((line, index) => colorWidgetLine(line, index, this.theme)), width);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
invalidate(): void {
|
|
419
|
-
this.cacheSignature = "";
|
|
420
|
-
this.cachedBaseLines = [];
|
|
421
|
-
this.cachedLines = [];
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
dispose(): void {
|
|
425
|
-
this.unsubscribeTheme();
|
|
426
|
-
this.unsubscribeEventBus();
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
render(width: number): string[] {
|
|
430
|
-
const runs = activeWidgetRuns(this.model.cwd, this.model.manifestCache, this.model.snapshotCache, this.model.preloadManifests);
|
|
431
|
-
const signature = `${this.buildSignature(runs)}:${this.model.notificationCount ?? 0}`;
|
|
432
|
-
// Time-based glyphs so the spinner animates every render call rather than
|
|
433
|
-
// only when state.frame is bumped (which happens at the slower data refresh).
|
|
434
|
-
const runningGlyph = spinnerFrame("widget-header");
|
|
435
|
-
const headerGlyph = runs.length ? spinnerFrame("widget-header") : " ";
|
|
436
|
-
|
|
437
|
-
if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
|
|
438
|
-
this.cachedBaseLines = buildCrewWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0).map((line, index) => {
|
|
439
|
-
if (index === 0 && line.length > 0) return `${headerGlyph}${line.slice(1)}`;
|
|
440
|
-
return line;
|
|
441
|
-
});
|
|
442
|
-
this.cachedLines = this.colorize(this.cachedBaseLines, width);
|
|
443
|
-
this.cachedWidth = width;
|
|
444
|
-
this.cachedTheme = this.theme;
|
|
445
|
-
this.cacheSignature = signature;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
if (runs.length === 0) {
|
|
449
|
-
this.invalidate();
|
|
450
|
-
return [];
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Update only spinner and command icon on header line to avoid full re-color for every frame.
|
|
454
|
-
const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
|
|
455
|
-
this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
|
|
456
|
-
// Safety: ensure all lines fit within terminal width (handles emoji/CJK width mismatch)
|
|
457
|
-
return this.cachedLines.map((line) => truncate(line, width));
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export function updateCrewWidget(
|
|
462
|
-
ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui" | "sessionManager">,
|
|
463
|
-
state: CrewWidgetState,
|
|
464
|
-
config?: CrewUiConfig,
|
|
465
|
-
manifestCache?: ManifestCache,
|
|
466
|
-
snapshotCache?: RunSnapshotCache,
|
|
467
|
-
preloadedManifests?: TeamRunManifest[],
|
|
468
|
-
): void {
|
|
469
|
-
if (!ctx.hasUI) return;
|
|
470
|
-
state.frame += 1;
|
|
471
|
-
const maxLines = config?.widgetMaxLines ?? MAX_LINES_DEFAULT;
|
|
472
|
-
// Get workspaceId from sessionManager, fallback to ownerSessionId from active runs
|
|
473
|
-
let workspaceId = ctx.sessionManager?.getSessionId?.();
|
|
474
|
-
if (!workspaceId && manifestCache) {
|
|
475
|
-
const runs = manifestCache.list(20);
|
|
476
|
-
const active = runs.find((r) => r.status === "running" || r.status === "queued");
|
|
477
|
-
if (active?.ownerSessionId) workspaceId = active.ownerSessionId;
|
|
478
|
-
}
|
|
479
|
-
const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
|
|
480
|
-
const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0);
|
|
481
|
-
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
482
|
-
ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
|
|
483
|
-
const shouldClearLegacy = state.legacyCleared !== true || state.lastPlacement !== placement;
|
|
484
|
-
if (shouldClearLegacy) {
|
|
485
|
-
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
|
|
486
|
-
state.legacyCleared = true;
|
|
487
|
-
}
|
|
488
|
-
if (!lines.length) {
|
|
489
|
-
if (state.lastVisibility !== "hidden" || state.lastPlacement !== placement) {
|
|
490
|
-
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
|
|
491
|
-
state.lastVisibility = "hidden";
|
|
492
|
-
state.lastPlacement = placement;
|
|
493
|
-
state.lastKey = WIDGET_KEY;
|
|
494
|
-
state.lastMaxLines = maxLines;
|
|
495
|
-
state.lastCwd = ctx.cwd;
|
|
496
|
-
state.model = undefined;
|
|
497
|
-
}
|
|
498
|
-
requestRender(ctx);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
const needsWidgetInstall = state.lastVisibility !== "visible" || state.lastPlacement !== placement || state.lastKey !== WIDGET_KEY || state.lastMaxLines !== maxLines || state.lastCwd !== ctx.cwd || !state.model;
|
|
502
|
-
if (!state.model) state.model = { cwd: ctx.cwd, frame: state.frame, maxLines, notificationCount: state.notificationCount ?? 0, manifestCache, snapshotCache, preloadManifests: preloadedManifests };
|
|
503
|
-
else {
|
|
504
|
-
state.model.cwd = ctx.cwd;
|
|
505
|
-
state.model.frame = state.frame;
|
|
506
|
-
state.model.maxLines = maxLines;
|
|
507
|
-
state.model.notificationCount = state.notificationCount ?? 0;
|
|
508
|
-
state.model.manifestCache = manifestCache;
|
|
509
|
-
state.model.snapshotCache = snapshotCache;
|
|
510
|
-
state.model.preloadManifests = preloadedManifests;
|
|
511
|
-
}
|
|
512
|
-
if (needsWidgetInstall) {
|
|
513
|
-
const model = state.model;
|
|
514
|
-
setExtensionWidget(
|
|
515
|
-
ctx,
|
|
516
|
-
WIDGET_KEY,
|
|
517
|
-
((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme)) as never,
|
|
518
|
-
{ placement, persist: true },
|
|
519
|
-
);
|
|
520
|
-
state.lastVisibility = "visible";
|
|
521
|
-
state.lastPlacement = placement;
|
|
522
|
-
state.lastKey = WIDGET_KEY;
|
|
523
|
-
state.lastMaxLines = maxLines;
|
|
524
|
-
state.lastCwd = ctx.cwd;
|
|
525
|
-
}
|
|
526
|
-
requestRender(ctx);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState, config?: CrewUiConfig): void {
|
|
530
|
-
if (state.interval) clearInterval(state.interval);
|
|
531
|
-
state.interval = undefined;
|
|
532
|
-
if (ctx?.hasUI) {
|
|
533
|
-
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
534
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
535
|
-
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
|
|
536
|
-
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
|
|
537
|
-
state.lastVisibility = "hidden";
|
|
538
|
-
state.lastPlacement = placement;
|
|
539
|
-
state.lastKey = WIDGET_KEY;
|
|
540
|
-
state.model = undefined;
|
|
541
|
-
state.legacyCleared = true;
|
|
542
|
-
requestRender(ctx);
|
|
543
|
-
}
|
|
544
|
-
}
|