pi-crew 0.1.32 → 0.1.34
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/docs/architecture.md +3 -3
- package/docs/research-phase8-operator-experience-plan.md +819 -0
- package/docs/research-phase9-observability-reliability-plan.md +1190 -0
- package/docs/research-ui-optimization-plan.md +480 -0
- package/package.json +1 -1
- package/schema.json +14 -0
- package/src/config/config.ts +69 -0
- package/src/config/defaults.ts +7 -0
- package/src/extension/autonomous-policy.ts +56 -2
- package/src/extension/notification-router.ts +116 -0
- package/src/extension/notification-sink.ts +51 -0
- package/src/extension/register.ts +133 -35
- package/src/extension/registration/commands.ts +110 -3
- package/src/extension/registration/team-tool.ts +5 -2
- package/src/extension/registration/viewers.ts +3 -1
- package/src/extension/team-recommendation.ts +16 -8
- package/src/runtime/child-pi.ts +1 -0
- package/src/runtime/diagnostic-export.ts +107 -0
- package/src/runtime/pi-spawn.ts +4 -1
- package/src/runtime/task-packet.ts +11 -2
- package/src/runtime/task-runner/prompt-builder.ts +3 -0
- package/src/schema/config-schema.ts +11 -0
- package/src/ui/crew-widget.ts +350 -285
- package/src/ui/dashboard-panes/agents-pane.ts +25 -0
- package/src/ui/dashboard-panes/health-pane.ts +30 -0
- package/src/ui/dashboard-panes/mailbox-pane.ts +10 -0
- package/src/ui/dashboard-panes/progress-pane.ts +14 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +10 -0
- package/src/ui/heartbeat-aggregator.ts +53 -0
- package/src/ui/keybinding-map.ts +92 -0
- package/src/ui/live-run-sidebar.ts +20 -8
- package/src/ui/overlays/agent-picker-overlay.ts +57 -0
- package/src/ui/overlays/confirm-overlay.ts +58 -0
- package/src/ui/overlays/mailbox-compose-overlay.ts +144 -0
- package/src/ui/overlays/mailbox-compose-preview.ts +63 -0
- package/src/ui/overlays/mailbox-detail-overlay.ts +122 -0
- package/src/ui/pi-ui-compat.ts +57 -0
- package/src/ui/powerbar-publisher.ts +128 -94
- package/src/ui/render-scheduler.ts +103 -0
- package/src/ui/run-action-dispatcher.ts +107 -0
- package/src/ui/run-dashboard.ts +418 -372
- package/src/ui/run-snapshot-cache.ts +359 -0
- package/src/ui/snapshot-types.ts +47 -0
- package/src/ui/transcript-cache.ts +94 -0
- package/src/ui/transcript-viewer.ts +316 -302
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { readCrewAgents, agentsPath } from "../runtime/crew-agent-records.ts";
|
|
5
|
+
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
6
|
+
import { isActiveRunStatus } from "../runtime/process-status.ts";
|
|
7
|
+
import { readEvents, type TeamEvent } from "../state/event-log.ts";
|
|
8
|
+
import type { MailboxMessageStatus } from "../state/mailbox.ts";
|
|
9
|
+
import { loadRunManifestById } from "../state/state-store.ts";
|
|
10
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
11
|
+
import type { RunSnapshotCache, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TTL_MS = 250;
|
|
14
|
+
const DEFAULT_MAX_ENTRIES = 24;
|
|
15
|
+
const DEFAULT_RECENT_EVENTS = 20;
|
|
16
|
+
const DEFAULT_RECENT_OUTPUT_LINES = 20;
|
|
17
|
+
const MAX_TAIL_BYTES = 32 * 1024;
|
|
18
|
+
|
|
19
|
+
interface FileStamp {
|
|
20
|
+
mtimeMs: number;
|
|
21
|
+
size: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SnapshotStamps {
|
|
25
|
+
manifest: FileStamp;
|
|
26
|
+
tasks: FileStamp;
|
|
27
|
+
agents: FileStamp;
|
|
28
|
+
events: FileStamp;
|
|
29
|
+
mailbox: FileStamp;
|
|
30
|
+
output: FileStamp;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CacheEntry {
|
|
34
|
+
snapshot: RunUiSnapshot;
|
|
35
|
+
stamps: SnapshotStamps;
|
|
36
|
+
loadedAtMs: number;
|
|
37
|
+
lastAccessMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RunSnapshotCacheOptions {
|
|
41
|
+
ttlMs?: number;
|
|
42
|
+
maxEntries?: number;
|
|
43
|
+
recentEvents?: number;
|
|
44
|
+
recentOutputLines?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function zeroStamp(): FileStamp {
|
|
48
|
+
return { mtimeMs: 0, size: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stampFile(filePath: string | undefined): FileStamp {
|
|
52
|
+
if (!filePath) return zeroStamp();
|
|
53
|
+
try {
|
|
54
|
+
const stat = fs.statSync(filePath);
|
|
55
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
|
56
|
+
} catch {
|
|
57
|
+
return zeroStamp();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function combineStamps(stamps: FileStamp[]): FileStamp {
|
|
62
|
+
return stamps.reduce((acc, stamp) => ({ mtimeMs: Math.max(acc.mtimeMs, stamp.mtimeMs), size: acc.size + stamp.size }), zeroStamp());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mailboxStamp(manifest: TeamRunManifest): FileStamp {
|
|
66
|
+
const root = path.join(manifest.stateRoot, "mailbox");
|
|
67
|
+
const stamps: FileStamp[] = [
|
|
68
|
+
stampFile(path.join(root, "inbox.jsonl")),
|
|
69
|
+
stampFile(path.join(root, "outbox.jsonl")),
|
|
70
|
+
stampFile(path.join(root, "delivery.json")),
|
|
71
|
+
];
|
|
72
|
+
const tasksRoot = path.join(root, "tasks");
|
|
73
|
+
try {
|
|
74
|
+
for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
|
|
75
|
+
if (!entry.isDirectory()) continue;
|
|
76
|
+
stamps.push(stampFile(path.join(tasksRoot, entry.name, "inbox.jsonl")));
|
|
77
|
+
stamps.push(stampFile(path.join(tasksRoot, entry.name, "outbox.jsonl")));
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// No task mailbox yet.
|
|
81
|
+
}
|
|
82
|
+
return combineStamps(stamps);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function outputStamp(agents: CrewAgentRecord[]): FileStamp {
|
|
86
|
+
return combineStamps(agents.map((agent) => stampFile(agent.outputPath)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sameStamp(a: FileStamp, b: FileStamp): boolean {
|
|
90
|
+
return a.mtimeMs === b.mtimeMs && a.size === b.size;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sameStamps(a: SnapshotStamps, b: SnapshotStamps): boolean {
|
|
94
|
+
return sameStamp(a.manifest, b.manifest)
|
|
95
|
+
&& sameStamp(a.tasks, b.tasks)
|
|
96
|
+
&& sameStamp(a.agents, b.agents)
|
|
97
|
+
&& sameStamp(a.events, b.events)
|
|
98
|
+
&& sameStamp(a.mailbox, b.mailbox)
|
|
99
|
+
&& sameStamp(a.output, b.output);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readTasks(tasksPath: string): TeamTaskState[] {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8")) as unknown;
|
|
105
|
+
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`Failed to parse tasks at ${tasksPath}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeRecentEvents(eventsPath: string, limit: number): TeamEvent[] {
|
|
112
|
+
try {
|
|
113
|
+
return readEvents(eventsPath).slice(-limit);
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function tailLines(filePath: string, limit: number): string[] {
|
|
120
|
+
if (limit <= 0) return [];
|
|
121
|
+
try {
|
|
122
|
+
const stat = fs.statSync(filePath);
|
|
123
|
+
const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
|
|
124
|
+
const fd = fs.openSync(filePath, "r");
|
|
125
|
+
try {
|
|
126
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
127
|
+
fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
|
|
128
|
+
return buffer.toString("utf-8").split(/\r?\n/).filter(Boolean).slice(-limit);
|
|
129
|
+
} finally {
|
|
130
|
+
fs.closeSync(fd);
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function recentOutputLines(agents: CrewAgentRecord[], limit: number): string[] {
|
|
138
|
+
const fromProgress = agents.flatMap((agent) => agent.progress?.recentOutput ?? []);
|
|
139
|
+
const fromFiles = agents.flatMap((agent) => agent.outputPath ? tailLines(agent.outputPath, limit) : []);
|
|
140
|
+
return [...fromProgress, ...fromFiles].map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).slice(-limit);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function progressFromTasks(tasks: TeamTaskState[]): RunUiProgress {
|
|
144
|
+
return {
|
|
145
|
+
total: tasks.length,
|
|
146
|
+
completed: tasks.filter((task) => task.status === "completed").length,
|
|
147
|
+
running: tasks.filter((task) => task.status === "running").length,
|
|
148
|
+
failed: tasks.filter((task) => task.status === "failed").length,
|
|
149
|
+
queued: tasks.filter((task) => task.status === "queued").length,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function usageFrom(tasks: TeamTaskState[], agents: CrewAgentRecord[]): RunUiUsage {
|
|
154
|
+
const taskUsage = tasks.reduce((acc, task) => {
|
|
155
|
+
acc.tokensIn += task.usage?.input ?? 0;
|
|
156
|
+
acc.tokensOut += task.usage?.output ?? 0;
|
|
157
|
+
acc.toolUses += task.agentProgress?.toolCount ?? 0;
|
|
158
|
+
return acc;
|
|
159
|
+
}, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
|
|
160
|
+
if (taskUsage.tokensIn || taskUsage.tokensOut || taskUsage.toolUses) return taskUsage;
|
|
161
|
+
return agents.reduce((acc, agent) => {
|
|
162
|
+
acc.tokensIn += agent.usage?.input ?? 0;
|
|
163
|
+
acc.tokensOut += agent.usage?.output ?? agent.progress?.tokens ?? 0;
|
|
164
|
+
acc.toolUses += agent.toolUses ?? agent.progress?.toolCount ?? 0;
|
|
165
|
+
return acc;
|
|
166
|
+
}, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isMailboxStatus(value: unknown): value is MailboxMessageStatus {
|
|
170
|
+
return value === "queued" || value === "delivered" || value === "acknowledged";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readDeliveryMessages(filePath: string): Record<string, MailboxMessageStatus> {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
|
|
176
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
177
|
+
const messages = (parsed as { messages?: unknown }).messages;
|
|
178
|
+
if (!messages || typeof messages !== "object" || Array.isArray(messages)) return {};
|
|
179
|
+
const output: Record<string, MailboxMessageStatus> = {};
|
|
180
|
+
for (const [id, status] of Object.entries(messages)) if (isMailboxStatus(status)) output[id] = status;
|
|
181
|
+
return output;
|
|
182
|
+
} catch {
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): number {
|
|
188
|
+
try {
|
|
189
|
+
return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).reduce((count, line) => {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(line) as unknown;
|
|
192
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return count;
|
|
193
|
+
const message = parsed as { id?: unknown; status?: unknown };
|
|
194
|
+
if (typeof message.id !== "string" || !isMailboxStatus(message.status)) return count;
|
|
195
|
+
return message.status !== "acknowledged" && delivery[message.id] !== "acknowledged" ? count + 1 : count;
|
|
196
|
+
} catch {
|
|
197
|
+
return count;
|
|
198
|
+
}
|
|
199
|
+
}, 0);
|
|
200
|
+
} catch {
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
|
|
206
|
+
const root = path.join(manifest.stateRoot, "mailbox");
|
|
207
|
+
const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
|
|
208
|
+
let inboxUnread = readMailboxCounts(path.join(root, "inbox.jsonl"), delivery);
|
|
209
|
+
let outboxPending = readMailboxCounts(path.join(root, "outbox.jsonl"), delivery);
|
|
210
|
+
const tasksRoot = path.join(root, "tasks");
|
|
211
|
+
try {
|
|
212
|
+
for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
|
|
213
|
+
if (!entry.isDirectory()) continue;
|
|
214
|
+
inboxUnread += readMailboxCounts(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
|
|
215
|
+
outboxPending += readMailboxCounts(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// No task mailboxes yet.
|
|
219
|
+
}
|
|
220
|
+
const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
|
|
221
|
+
return { inboxUnread, outboxPending, needsAttention: inboxUnread + attentionAgents };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, stamps: SnapshotStamps): string {
|
|
225
|
+
const digest = createHash("sha256");
|
|
226
|
+
digest.update(JSON.stringify({
|
|
227
|
+
run: [input.manifest.runId, input.manifest.status, input.manifest.updatedAt, input.manifest.artifacts.length],
|
|
228
|
+
tasks: input.tasks.map((task) => [task.id, task.status, task.startedAt, task.finishedAt, task.agentProgress, task.usage]),
|
|
229
|
+
agents: input.agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt, agent.toolUses, agent.progress, agent.usage, agent.model]),
|
|
230
|
+
progress: input.progress,
|
|
231
|
+
usage: input.usage,
|
|
232
|
+
mailbox: input.mailbox,
|
|
233
|
+
events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message]),
|
|
234
|
+
output: input.recentOutputLines,
|
|
235
|
+
stamps,
|
|
236
|
+
}));
|
|
237
|
+
return digest.digest("hex").slice(0, 16);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function stampsFor(manifest: TeamRunManifest, agents: CrewAgentRecord[]): SnapshotStamps {
|
|
241
|
+
return {
|
|
242
|
+
manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
|
|
243
|
+
tasks: stampFile(manifest.tasksPath),
|
|
244
|
+
agents: stampFile(agentsPath(manifest)),
|
|
245
|
+
events: stampFile(manifest.eventsPath),
|
|
246
|
+
mailbox: mailboxStamp(manifest),
|
|
247
|
+
output: outputStamp(agents),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOptions = {}): RunSnapshotCache {
|
|
252
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
253
|
+
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
254
|
+
const recentEventsLimit = options.recentEvents ?? DEFAULT_RECENT_EVENTS;
|
|
255
|
+
const recentOutputLimit = options.recentOutputLines ?? DEFAULT_RECENT_OUTPUT_LINES;
|
|
256
|
+
const entries = new Map<string, CacheEntry>();
|
|
257
|
+
|
|
258
|
+
function touch(runId: string, entry: CacheEntry): RunUiSnapshot {
|
|
259
|
+
entry.lastAccessMs = Date.now();
|
|
260
|
+
if (entries.has(runId)) {
|
|
261
|
+
entries.delete(runId);
|
|
262
|
+
entries.set(runId, entry);
|
|
263
|
+
}
|
|
264
|
+
return entry.snapshot;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function evictIfNeeded(): void {
|
|
268
|
+
while (entries.size > maxEntries) {
|
|
269
|
+
const oldestInactive = [...entries.entries()].find(([, entry]) => !isActiveRunStatus(entry.snapshot.manifest.status));
|
|
270
|
+
const key = oldestInactive?.[0] ?? entries.keys().next().value;
|
|
271
|
+
if (!key) break;
|
|
272
|
+
entries.delete(key);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function build(runId: string, previous?: CacheEntry): CacheEntry {
|
|
277
|
+
let loaded: ReturnType<typeof loadRunManifestById>;
|
|
278
|
+
try {
|
|
279
|
+
loaded = loadRunManifestById(cwd, runId);
|
|
280
|
+
} catch {
|
|
281
|
+
if (previous) return previous;
|
|
282
|
+
throw new Error(`Run '${runId}' could not be parsed.`);
|
|
283
|
+
}
|
|
284
|
+
if (!loaded) {
|
|
285
|
+
if (previous) return previous;
|
|
286
|
+
throw new Error(`Run '${runId}' not found.`);
|
|
287
|
+
}
|
|
288
|
+
let tasks: TeamTaskState[];
|
|
289
|
+
let agents: CrewAgentRecord[];
|
|
290
|
+
try {
|
|
291
|
+
tasks = readTasks(loaded.manifest.tasksPath);
|
|
292
|
+
agents = readCrewAgents(loaded.manifest);
|
|
293
|
+
} catch {
|
|
294
|
+
if (previous) return previous;
|
|
295
|
+
throw new Error(`Run '${runId}' could not be parsed.`);
|
|
296
|
+
}
|
|
297
|
+
const mailbox = mailboxFrom(loaded.manifest, agents);
|
|
298
|
+
const base = {
|
|
299
|
+
runId: loaded.manifest.runId,
|
|
300
|
+
cwd: loaded.manifest.cwd,
|
|
301
|
+
manifest: loaded.manifest,
|
|
302
|
+
tasks,
|
|
303
|
+
agents,
|
|
304
|
+
progress: progressFromTasks(tasks),
|
|
305
|
+
usage: usageFrom(tasks, agents),
|
|
306
|
+
mailbox,
|
|
307
|
+
recentEvents: safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit),
|
|
308
|
+
recentOutputLines: recentOutputLines(agents, recentOutputLimit),
|
|
309
|
+
};
|
|
310
|
+
const stamps = stampsFor(loaded.manifest, agents);
|
|
311
|
+
const snapshot: RunUiSnapshot = { ...base, fetchedAt: Date.now(), signature: signatureFor(base, stamps) };
|
|
312
|
+
return { snapshot, stamps, loadedAtMs: snapshot.fetchedAt, lastAccessMs: snapshot.fetchedAt };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function currentStamps(previous: CacheEntry): SnapshotStamps {
|
|
316
|
+
const manifest = previous.snapshot.manifest;
|
|
317
|
+
return {
|
|
318
|
+
manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
|
|
319
|
+
tasks: stampFile(manifest.tasksPath),
|
|
320
|
+
agents: stampFile(agentsPath(manifest)),
|
|
321
|
+
events: stampFile(manifest.eventsPath),
|
|
322
|
+
mailbox: mailboxStamp(manifest),
|
|
323
|
+
output: outputStamp(previous.snapshot.agents),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
get(runId: string): RunUiSnapshot | undefined {
|
|
329
|
+
const entry = entries.get(runId);
|
|
330
|
+
return entry ? touch(runId, entry) : undefined;
|
|
331
|
+
},
|
|
332
|
+
refresh(runId: string): RunUiSnapshot {
|
|
333
|
+
const previous = entries.get(runId);
|
|
334
|
+
const entry = build(runId, previous);
|
|
335
|
+
entries.set(runId, entry);
|
|
336
|
+
evictIfNeeded();
|
|
337
|
+
return entry.snapshot;
|
|
338
|
+
},
|
|
339
|
+
refreshIfStale(runId: string): RunUiSnapshot {
|
|
340
|
+
const previous = entries.get(runId);
|
|
341
|
+
if (!previous) return this.refresh(runId);
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
|
|
344
|
+
const stamps = currentStamps(previous);
|
|
345
|
+
if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
|
|
346
|
+
return this.refresh(runId);
|
|
347
|
+
},
|
|
348
|
+
invalidate(runId?: string): void {
|
|
349
|
+
if (runId) entries.delete(runId);
|
|
350
|
+
else entries.clear();
|
|
351
|
+
},
|
|
352
|
+
snapshotsByKey(): Map<string, RunUiSnapshot> {
|
|
353
|
+
return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
|
|
354
|
+
},
|
|
355
|
+
dispose(): void {
|
|
356
|
+
entries.clear();
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
2
|
+
import type { TeamEvent } from "../state/event-log.ts";
|
|
3
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface RunUiProgress {
|
|
6
|
+
total: number;
|
|
7
|
+
completed: number;
|
|
8
|
+
running: number;
|
|
9
|
+
failed: number;
|
|
10
|
+
queued: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RunUiUsage {
|
|
14
|
+
tokensIn: number;
|
|
15
|
+
tokensOut: number;
|
|
16
|
+
toolUses: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunUiMailbox {
|
|
20
|
+
inboxUnread: number;
|
|
21
|
+
outboxPending: number;
|
|
22
|
+
needsAttention: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunUiSnapshot {
|
|
26
|
+
runId: string;
|
|
27
|
+
cwd: string;
|
|
28
|
+
fetchedAt: number;
|
|
29
|
+
signature: string;
|
|
30
|
+
manifest: TeamRunManifest;
|
|
31
|
+
tasks: TeamTaskState[];
|
|
32
|
+
agents: CrewAgentRecord[];
|
|
33
|
+
progress: RunUiProgress;
|
|
34
|
+
usage: RunUiUsage;
|
|
35
|
+
mailbox: RunUiMailbox;
|
|
36
|
+
recentEvents: TeamEvent[];
|
|
37
|
+
recentOutputLines: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RunSnapshotCache {
|
|
41
|
+
get(runId: string): RunUiSnapshot | undefined;
|
|
42
|
+
refresh(runId: string): RunUiSnapshot;
|
|
43
|
+
refreshIfStale(runId: string): RunUiSnapshot;
|
|
44
|
+
invalidate(runId?: string): void;
|
|
45
|
+
snapshotsByKey(): Map<string, RunUiSnapshot>;
|
|
46
|
+
dispose?(): void;
|
|
47
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface TranscriptCacheEntry {
|
|
4
|
+
path: string;
|
|
5
|
+
mtimeMs: number;
|
|
6
|
+
size: number;
|
|
7
|
+
lines: string[];
|
|
8
|
+
parsedAt: number;
|
|
9
|
+
readCount: number;
|
|
10
|
+
mode: "tail" | "full";
|
|
11
|
+
bytesRead: number;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TranscriptReadOptions {
|
|
16
|
+
maxTailBytes?: number;
|
|
17
|
+
full?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TRANSCRIPT_CACHE_TTL_MS = 500;
|
|
21
|
+
const DEFAULT_TAIL_BYTES = 256 * 1024;
|
|
22
|
+
const transcriptCache = new Map<string, TranscriptCacheEntry>();
|
|
23
|
+
|
|
24
|
+
function cacheKey(path: string, options: Required<Pick<TranscriptReadOptions, "full">> & { maxTailBytes: number }): string {
|
|
25
|
+
return `${path}:${options.full ? "full" : `tail:${options.maxTailBytes}`}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clearTranscriptCache(path?: string): void {
|
|
29
|
+
if (!path) {
|
|
30
|
+
transcriptCache.clear();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const key of [...transcriptCache.keys()]) if (key === path || key.startsWith(`${path}:`)) transcriptCache.delete(key);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getTranscriptCacheEntry(path: string, options: TranscriptReadOptions = {}): TranscriptCacheEntry | undefined {
|
|
37
|
+
const normalized = { full: options.full === true, maxTailBytes: options.maxTailBytes ?? DEFAULT_TAIL_BYTES };
|
|
38
|
+
return transcriptCache.get(cacheKey(path, normalized)) ?? transcriptCache.get(path);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readTranscriptText(path: string, stat: fs.Stats, options: Required<Pick<TranscriptReadOptions, "full">> & { maxTailBytes: number }): { text: string; bytesRead: number; truncated: boolean } {
|
|
42
|
+
if (options.full || stat.size <= options.maxTailBytes) {
|
|
43
|
+
return { text: fs.readFileSync(path, "utf-8"), bytesRead: stat.size, truncated: false };
|
|
44
|
+
}
|
|
45
|
+
const bytesToRead = Math.min(stat.size, options.maxTailBytes);
|
|
46
|
+
const fd = fs.openSync(path, "r");
|
|
47
|
+
try {
|
|
48
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
49
|
+
fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
|
|
50
|
+
let text = buffer.toString("utf-8");
|
|
51
|
+
const firstNewline = text.search(/\r?\n/);
|
|
52
|
+
if (firstNewline >= 0) text = text.slice(firstNewline + (text[firstNewline] === "\r" && text[firstNewline + 1] === "\n" ? 2 : 1));
|
|
53
|
+
return { text, bytesRead: bytesToRead, truncated: true };
|
|
54
|
+
} finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readTranscriptLinesCached(path: string, parse: (text: string) => string[], now = Date.now(), options: TranscriptReadOptions = {}): string[] {
|
|
60
|
+
const normalized = { full: options.full === true, maxTailBytes: Math.max(1024, options.maxTailBytes ?? DEFAULT_TAIL_BYTES) };
|
|
61
|
+
const key = cacheKey(path, normalized);
|
|
62
|
+
const previous = transcriptCache.get(key);
|
|
63
|
+
let stat: fs.Stats;
|
|
64
|
+
try {
|
|
65
|
+
stat = fs.statSync(path);
|
|
66
|
+
} catch {
|
|
67
|
+
return previous?.lines ?? [];
|
|
68
|
+
}
|
|
69
|
+
if (previous && previous.mtimeMs === stat.mtimeMs && previous.size === stat.size) {
|
|
70
|
+
if (now - previous.parsedAt >= TRANSCRIPT_CACHE_TTL_MS) previous.parsedAt = now;
|
|
71
|
+
return previous.lines;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const read = readTranscriptText(path, stat, normalized);
|
|
75
|
+
const lines = parse(read.text);
|
|
76
|
+
const entry: TranscriptCacheEntry = {
|
|
77
|
+
path,
|
|
78
|
+
mtimeMs: stat.mtimeMs,
|
|
79
|
+
size: stat.size,
|
|
80
|
+
lines,
|
|
81
|
+
parsedAt: now,
|
|
82
|
+
readCount: (previous?.readCount ?? 0) + 1,
|
|
83
|
+
mode: normalized.full ? "full" : "tail",
|
|
84
|
+
bytesRead: read.bytesRead,
|
|
85
|
+
truncated: read.truncated,
|
|
86
|
+
};
|
|
87
|
+
transcriptCache.set(key, entry);
|
|
88
|
+
return lines;
|
|
89
|
+
} catch {
|
|
90
|
+
return previous?.lines ?? [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_TRANSCRIPT_TAIL_BYTES = DEFAULT_TAIL_BYTES;
|