@omercnet/paseo-agent-crew 0.2.3
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 +48 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bun.lock +1213 -0
- package/client/crew.ts +267 -0
- package/client/main.tsx +1043 -0
- package/docs/images/agent-crew-action.png +0 -0
- package/docs/images/agent-crew-overview.png +0 -0
- package/index.client.tsx +24 -0
- package/package.json +57 -0
- package/paseo-plugin.json +6 -0
- package/tsconfig.json +16 -0
package/client/crew.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { usePaseo } from "@getpaseo/plugin/client";
|
|
2
|
+
|
|
3
|
+
export type PaseoApi = ReturnType<typeof usePaseo>;
|
|
4
|
+
export type PaseoWorkspace = Awaited<ReturnType<PaseoApi["workspaces"]["list"]>>["entries"][number];
|
|
5
|
+
export type AgentEntry = Awaited<ReturnType<PaseoApi["agents"]["list"]>>["entries"][number];
|
|
6
|
+
type AgentSnapshot = AgentEntry["agent"];
|
|
7
|
+
|
|
8
|
+
const LEGACY_PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
|
9
|
+
|
|
10
|
+
export type CrewState = "needs-input" | "failed" | "working" | "ready" | "idle" | "closed";
|
|
11
|
+
|
|
12
|
+
export const CREW_STATES: readonly CrewState[] = [
|
|
13
|
+
"needs-input",
|
|
14
|
+
"failed",
|
|
15
|
+
"working",
|
|
16
|
+
"ready",
|
|
17
|
+
"idle",
|
|
18
|
+
"closed",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const CREW_STATE_LABELS: Record<CrewState, string> = {
|
|
22
|
+
"needs-input": "Needs input",
|
|
23
|
+
failed: "Failed",
|
|
24
|
+
working: "Working",
|
|
25
|
+
ready: "Ready",
|
|
26
|
+
idle: "Idle",
|
|
27
|
+
closed: "Closed",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CREW_STATE_ORDER: Record<CrewState, number> = {
|
|
31
|
+
"needs-input": 0,
|
|
32
|
+
failed: 1,
|
|
33
|
+
ready: 2,
|
|
34
|
+
working: 3,
|
|
35
|
+
idle: 4,
|
|
36
|
+
closed: 5,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface CrewNode {
|
|
40
|
+
entry: AgentEntry;
|
|
41
|
+
depth: number;
|
|
42
|
+
descendantCount: number;
|
|
43
|
+
contextOnly: boolean;
|
|
44
|
+
member: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CrewTreeOptions {
|
|
48
|
+
state: CrewState | null;
|
|
49
|
+
query: string;
|
|
50
|
+
workspaceNames?: ReadonlyMap<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parentAgentId(agent: AgentSnapshot): string | null {
|
|
54
|
+
const firstClass = "parentAgentId" in agent ? agent.parentAgentId : null;
|
|
55
|
+
if (typeof firstClass === "string" && firstClass.trim()) return firstClass.trim();
|
|
56
|
+
const legacy = agent.labels?.[LEGACY_PARENT_AGENT_ID_LABEL];
|
|
57
|
+
return typeof legacy === "string" && legacy.trim().length > 0 ? legacy.trim() : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function crewState(agent: AgentSnapshot): CrewState {
|
|
61
|
+
if (agent.status === "error") return "failed";
|
|
62
|
+
if ((agent.pendingPermissions?.length ?? 0) > 0 || agent.attentionReason === "permission") {
|
|
63
|
+
return "needs-input";
|
|
64
|
+
}
|
|
65
|
+
if (agent.status === "running" || agent.status === "initializing") return "working";
|
|
66
|
+
if (agent.requiresAttention || agent.attentionReason === "finished") return "ready";
|
|
67
|
+
if (agent.status === "closed") return "closed";
|
|
68
|
+
return "idle";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isWorking(agent: AgentSnapshot): boolean {
|
|
72
|
+
return agent.status === "running" || agent.status === "initializing";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function agentTitle(entry: AgentEntry): string {
|
|
76
|
+
const title = entry.agent.title?.trim();
|
|
77
|
+
if (title) return title;
|
|
78
|
+
return `Agent ${entry.agent.id.slice(0, 8)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function agentAgeTimestamp(agent: AgentSnapshot): number {
|
|
82
|
+
const value = agent.attentionTimestamp ?? agent.activeTurn?.startedAt ?? agent.updatedAt;
|
|
83
|
+
const timestamp = Date.parse(value ?? "");
|
|
84
|
+
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatAge(timestamp: number, now: number): string {
|
|
88
|
+
if (timestamp <= 0) return "";
|
|
89
|
+
const seconds = Math.max(0, Math.floor((now - timestamp) / 1_000));
|
|
90
|
+
if (seconds < 60) return "now";
|
|
91
|
+
const minutes = Math.floor(seconds / 60);
|
|
92
|
+
if (minutes < 60) return `${minutes}m`;
|
|
93
|
+
const hours = Math.floor(minutes / 60);
|
|
94
|
+
if (hours < 24) return `${hours}h`;
|
|
95
|
+
return `${Math.floor(hours / 24)}d`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function crewCounts(nodes: readonly CrewNode[]): Record<CrewState, number> {
|
|
99
|
+
const counts: Record<CrewState, number> = {
|
|
100
|
+
"needs-input": 0,
|
|
101
|
+
failed: 0,
|
|
102
|
+
working: 0,
|
|
103
|
+
ready: 0,
|
|
104
|
+
idle: 0,
|
|
105
|
+
closed: 0,
|
|
106
|
+
};
|
|
107
|
+
for (const node of nodes) {
|
|
108
|
+
if (node.member) counts[crewState(node.entry.agent)] += 1;
|
|
109
|
+
}
|
|
110
|
+
return counts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function compareEntries(left: AgentEntry, right: AgentEntry): number {
|
|
114
|
+
const byState =
|
|
115
|
+
CREW_STATE_ORDER[crewState(left.agent)] - CREW_STATE_ORDER[crewState(right.agent)];
|
|
116
|
+
if (byState !== 0) return byState;
|
|
117
|
+
const byCreated = Date.parse(left.agent.createdAt) - Date.parse(right.agent.createdAt);
|
|
118
|
+
if (byCreated !== 0) return byCreated;
|
|
119
|
+
return left.agent.id.localeCompare(right.agent.id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function matches(
|
|
123
|
+
entry: AgentEntry,
|
|
124
|
+
query: string,
|
|
125
|
+
workspaceNames: ReadonlyMap<string, string>,
|
|
126
|
+
): boolean {
|
|
127
|
+
if (!query) return true;
|
|
128
|
+
const agent = entry.agent;
|
|
129
|
+
const values = [
|
|
130
|
+
agent.title,
|
|
131
|
+
agent.id,
|
|
132
|
+
agent.provider,
|
|
133
|
+
agent.model,
|
|
134
|
+
agent.cwd,
|
|
135
|
+
agent.workspaceId ? workspaceNames.get(agent.workspaceId) : undefined,
|
|
136
|
+
...Object.values(agent.labels ?? {}),
|
|
137
|
+
];
|
|
138
|
+
return values.some((value) => value?.toLowerCase().includes(query));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildCrewForest(
|
|
142
|
+
entries: readonly AgentEntry[],
|
|
143
|
+
workspaceId: string,
|
|
144
|
+
options: CrewTreeOptions = { state: null, query: "" },
|
|
145
|
+
): CrewNode[] {
|
|
146
|
+
const entriesById = new Map(
|
|
147
|
+
entries.filter((entry) => !entry.agent.archivedAt).map((entry) => [entry.agent.id, entry]),
|
|
148
|
+
);
|
|
149
|
+
const childrenByParent = new Map<string, AgentEntry[]>();
|
|
150
|
+
for (const entry of entriesById.values()) {
|
|
151
|
+
const parentId = parentAgentId(entry.agent);
|
|
152
|
+
if (!parentId) continue;
|
|
153
|
+
const children = childrenByParent.get(parentId) ?? [];
|
|
154
|
+
children.push(entry);
|
|
155
|
+
childrenByParent.set(parentId, children);
|
|
156
|
+
}
|
|
157
|
+
for (const children of childrenByParent.values()) children.sort(compareEntries);
|
|
158
|
+
|
|
159
|
+
const workspaceMembers = [...entriesById.values()].filter(
|
|
160
|
+
(entry) => entry.agent.workspaceId === workspaceId,
|
|
161
|
+
);
|
|
162
|
+
const memberIds = new Set(workspaceMembers.map((entry) => entry.agent.id));
|
|
163
|
+
const descendantsToVisit = [...memberIds];
|
|
164
|
+
for (let index = 0; index < descendantsToVisit.length; index += 1) {
|
|
165
|
+
const agentId = descendantsToVisit[index];
|
|
166
|
+
if (!agentId) continue;
|
|
167
|
+
for (const child of childrenByParent.get(agentId) ?? []) {
|
|
168
|
+
if (memberIds.has(child.agent.id)) continue;
|
|
169
|
+
memberIds.add(child.agent.id);
|
|
170
|
+
descendantsToVisit.push(child.agent.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const contextIds = new Set<string>();
|
|
175
|
+
for (const member of workspaceMembers) {
|
|
176
|
+
let parentId = parentAgentId(member.agent);
|
|
177
|
+
const ancestors = new Set<string>([member.agent.id]);
|
|
178
|
+
while (parentId && !ancestors.has(parentId)) {
|
|
179
|
+
ancestors.add(parentId);
|
|
180
|
+
const parent = entriesById.get(parentId);
|
|
181
|
+
if (!parent) break;
|
|
182
|
+
if (!memberIds.has(parentId)) contextIds.add(parentId);
|
|
183
|
+
parentId = parentAgentId(parent.agent);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const visibleIds = new Set([...memberIds, ...contextIds]);
|
|
188
|
+
const visibleEntries = [...visibleIds]
|
|
189
|
+
.map((id) => entriesById.get(id))
|
|
190
|
+
.filter((entry): entry is AgentEntry => entry !== undefined);
|
|
191
|
+
const roots = visibleEntries
|
|
192
|
+
.filter((entry) => {
|
|
193
|
+
const parentId = parentAgentId(entry.agent);
|
|
194
|
+
return !parentId || !visibleIds.has(parentId);
|
|
195
|
+
})
|
|
196
|
+
.sort(compareEntries);
|
|
197
|
+
const query = options.query.trim().toLowerCase();
|
|
198
|
+
const workspaceNames = options.workspaceNames ?? new Map<string, string>();
|
|
199
|
+
|
|
200
|
+
function countDescendants(agentId: string): number {
|
|
201
|
+
const seen = new Set([agentId]);
|
|
202
|
+
const pending = [...(childrenByParent.get(agentId) ?? [])];
|
|
203
|
+
let total = 0;
|
|
204
|
+
while (pending.length > 0) {
|
|
205
|
+
const child = pending.pop();
|
|
206
|
+
if (!child || seen.has(child.agent.id) || !visibleIds.has(child.agent.id)) continue;
|
|
207
|
+
seen.add(child.agent.id);
|
|
208
|
+
if (memberIds.has(child.agent.id)) total += 1;
|
|
209
|
+
pending.push(...(childrenByParent.get(child.agent.id) ?? []));
|
|
210
|
+
}
|
|
211
|
+
return total;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function visit(entry: AgentEntry, depth: number, ancestors: ReadonlySet<string>): CrewNode[] {
|
|
215
|
+
if (ancestors.has(entry.agent.id)) return [];
|
|
216
|
+
const nextAncestors = new Set(ancestors).add(entry.agent.id);
|
|
217
|
+
const descendants = (childrenByParent.get(entry.agent.id) ?? []).flatMap((child) =>
|
|
218
|
+
visibleIds.has(child.agent.id) ? visit(child, depth + 1, nextAncestors) : [],
|
|
219
|
+
);
|
|
220
|
+
const member = memberIds.has(entry.agent.id);
|
|
221
|
+
const selfMatches =
|
|
222
|
+
member &&
|
|
223
|
+
(options.state === null || crewState(entry.agent) === options.state) &&
|
|
224
|
+
matches(entry, query, workspaceNames);
|
|
225
|
+
if (!selfMatches && descendants.length === 0) return [];
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
entry,
|
|
229
|
+
depth,
|
|
230
|
+
descendantCount: countDescendants(entry.agent.id),
|
|
231
|
+
contextOnly: !selfMatches,
|
|
232
|
+
member,
|
|
233
|
+
},
|
|
234
|
+
...descendants,
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const nodes: CrewNode[] = [];
|
|
239
|
+
const emittedIds = new Set<string>();
|
|
240
|
+
function appendTree(entry: AgentEntry) {
|
|
241
|
+
const tree = visit(entry, 0, new Set());
|
|
242
|
+
for (const node of tree) emittedIds.add(node.entry.agent.id);
|
|
243
|
+
nodes.push(...tree);
|
|
244
|
+
}
|
|
245
|
+
for (const root of roots) appendTree(root);
|
|
246
|
+
for (const entry of visibleEntries.sort(compareEntries)) {
|
|
247
|
+
if (!emittedIds.has(entry.agent.id)) appendTree(entry);
|
|
248
|
+
}
|
|
249
|
+
return nodes;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function collapseCrewNodes(
|
|
253
|
+
nodes: readonly CrewNode[],
|
|
254
|
+
collapsedAgentIds: ReadonlySet<string>,
|
|
255
|
+
): CrewNode[] {
|
|
256
|
+
const visible: CrewNode[] = [];
|
|
257
|
+
let hiddenBelowDepth: number | null = null;
|
|
258
|
+
for (const node of nodes) {
|
|
259
|
+
if (hiddenBelowDepth !== null && node.depth > hiddenBelowDepth) continue;
|
|
260
|
+
hiddenBelowDepth = null;
|
|
261
|
+
visible.push(node);
|
|
262
|
+
if (node.descendantCount > 0 && collapsedAgentIds.has(node.entry.agent.id)) {
|
|
263
|
+
hiddenBelowDepth = node.depth;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return visible;
|
|
267
|
+
}
|