@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.
@@ -0,0 +1,1043 @@
1
+ import { type PluginWorkspacePanelProps, usePaseo, useWorkspace } from "@getpaseo/plugin/client";
2
+ import {
3
+ FlatList,
4
+ Icon,
5
+ Modal,
6
+ ScrollView,
7
+ TextInput,
8
+ useToast,
9
+ } from "@getpaseo/plugin/client/react-native";
10
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
11
+ import { useEffect, useMemo, useState } from "react";
12
+ import { type ListRenderItemInfo, Pressable, StyleSheet, Text, View } from "react-native";
13
+ import {
14
+ type AgentEntry,
15
+ agentAgeTimestamp,
16
+ agentTitle,
17
+ buildCrewForest,
18
+ CREW_STATE_LABELS,
19
+ CREW_STATES,
20
+ type CrewNode,
21
+ type CrewState,
22
+ collapseCrewNodes,
23
+ crewCounts,
24
+ crewState,
25
+ formatAge,
26
+ isWorking,
27
+ type PaseoApi,
28
+ type PaseoWorkspace,
29
+ parentAgentId,
30
+ } from "./crew";
31
+
32
+ const PAGE_LIMIT = 200;
33
+ const MAX_PAGES = 10;
34
+ const REFRESH_DEBOUNCE_MS = 500;
35
+ const BACKSTOP_REFRESH_MS = 30_000;
36
+ const CLOCK_INTERVAL_MS = 15_000;
37
+
38
+ type CrewData = {
39
+ entries: AgentEntry[];
40
+ workspaceNames: ReadonlyMap<string, string>;
41
+ truncated: boolean;
42
+ };
43
+
44
+ type CrewAction =
45
+ | { kind: "send"; agentId: string; text: string; interrupted: boolean }
46
+ | { kind: "detach"; agentId: string }
47
+ | { kind: "archive"; agentId: string };
48
+
49
+ type PermissionRequest = AgentEntry["agent"]["pendingPermissions"][number];
50
+
51
+ type PermissionActionInput = {
52
+ agentId: string;
53
+ request: PermissionRequest;
54
+ behavior: "allow" | "deny";
55
+ };
56
+
57
+ type PermissionDialogState = { node: CrewNode; request: PermissionRequest } | null;
58
+ type DialogState = { kind: "message" | "detach" | "archive"; node: CrewNode } | null;
59
+
60
+ async function loadAgents(paseo: PaseoApi): Promise<{ entries: AgentEntry[]; truncated: boolean }> {
61
+ const entries: AgentEntry[] = [];
62
+ let cursor: string | undefined;
63
+ for (let page = 0; page < MAX_PAGES; page += 1) {
64
+ const result = await paseo.agents.list({
65
+ sort: [{ key: "updated_at", direction: "desc" }],
66
+ page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
67
+ });
68
+ entries.push(...result.entries);
69
+ cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
70
+ if (!cursor) return { entries, truncated: false };
71
+ }
72
+ return { entries, truncated: true };
73
+ }
74
+
75
+ async function loadWorkspaces(paseo: PaseoApi): Promise<PaseoWorkspace[]> {
76
+ const workspaces: PaseoWorkspace[] = [];
77
+ let cursor: string | undefined;
78
+ for (let page = 0; page < MAX_PAGES; page += 1) {
79
+ const result = await paseo.workspaces.list({
80
+ page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
81
+ });
82
+ workspaces.push(...result.entries);
83
+ cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
84
+ if (!cursor) break;
85
+ }
86
+ return workspaces;
87
+ }
88
+
89
+ async function loadCrewData(paseo: PaseoApi): Promise<CrewData> {
90
+ const [agents, workspaces] = await Promise.all([loadAgents(paseo), loadWorkspaces(paseo)]);
91
+ return {
92
+ entries: agents.entries,
93
+ truncated: agents.truncated,
94
+ workspaceNames: new Map(workspaces.map((workspace) => [workspace.id, workspace.name])),
95
+ };
96
+ }
97
+
98
+ function ActionButton({
99
+ accessibilityLabel,
100
+ color,
101
+ disabled,
102
+ icon,
103
+ onPress,
104
+ }: {
105
+ accessibilityLabel: string;
106
+ color: string;
107
+ disabled?: boolean;
108
+ icon: string;
109
+ onPress(): void;
110
+ }) {
111
+ return (
112
+ <Pressable
113
+ accessibilityRole="button"
114
+ accessibilityLabel={accessibilityLabel}
115
+ accessibilityState={{ disabled: disabled === true }}
116
+ disabled={disabled}
117
+ hitSlop={4}
118
+ onPress={onPress}
119
+ style={({ pressed }) => [
120
+ styles.actionButton,
121
+ pressed && styles.pressed,
122
+ disabled && styles.disabled,
123
+ ]}
124
+ >
125
+ <Icon name={icon} size={15} color={color} />
126
+ </Pressable>
127
+ );
128
+ }
129
+
130
+ function stateColor(
131
+ state: CrewState,
132
+ colors: PluginWorkspacePanelProps["theme"]["colors"],
133
+ ): string {
134
+ if (state === "failed") return colors.statusDanger;
135
+ if (state === "needs-input") return colors.statusWarning;
136
+ if (state === "ready") return colors.statusSuccess;
137
+ if (state === "working") return colors.accent;
138
+ return colors.foregroundMuted;
139
+ }
140
+
141
+ function permissionRequestText(request: PermissionRequest): string {
142
+ return request.title ?? request.name;
143
+ }
144
+
145
+ function permissionRequestDetails(request: PermissionRequest): string {
146
+ return [
147
+ `Kind: ${request.kind}`,
148
+ request.description ? `Description: ${request.description}` : null,
149
+ request.input ? `Input: ${JSON.stringify(request.input, null, 2)}` : null,
150
+ request.detail ? `Detail: ${JSON.stringify(request.detail, null, 2)}` : null,
151
+ request.actions?.length
152
+ ? `Actions: ${request.actions.map((action) => action.label).join(" · ")}`
153
+ : null,
154
+ request.suggestions?.length
155
+ ? `Suggestions: ${JSON.stringify(request.suggestions, null, 2)}`
156
+ : null,
157
+ ]
158
+ .filter((line): line is string => Boolean(line))
159
+ .join("\n\n");
160
+ }
161
+
162
+ function permissionErrorMessage(error: unknown): string {
163
+ const message = error instanceof Error ? error.message : "Permission decision failed";
164
+ const lower = message.toLowerCase();
165
+ if (
166
+ lower.includes("already resolved") ||
167
+ lower.includes("not pending") ||
168
+ lower.includes("no longer pending") ||
169
+ lower.includes("request not found")
170
+ ) {
171
+ return "Permission request was already resolved.";
172
+ }
173
+ if (lower.includes("network") || lower.includes("transport") || lower.includes("ipc")) {
174
+ return "Could not reach the daemon to respond to the permission request.";
175
+ }
176
+ return message;
177
+ }
178
+
179
+ export function AgentCrew({
180
+ theme,
181
+ layout,
182
+ host,
183
+ workspaceId,
184
+ navigation,
185
+ }: PluginWorkspacePanelProps) {
186
+ const paseo = usePaseo();
187
+ const toast = useToast();
188
+ const queryClient = useQueryClient();
189
+ const workspaceTitle = useWorkspace(workspaceId, ({ name, title }) => title?.trim() || name);
190
+ const queryKey = useMemo(() => ["agent-crew", "directory", host.id], [host.id]);
191
+ const { data, error, isPending, isFetching, refetch } = useQuery({
192
+ queryKey,
193
+ queryFn: () => loadCrewData(paseo),
194
+ refetchInterval: BACKSTOP_REFRESH_MS,
195
+ });
196
+
197
+ const [selectedState, setSelectedState] = useState<CrewState | null>(null);
198
+ const [query, setQuery] = useState("");
199
+ const [now, setNow] = useState(() => Date.now());
200
+ const [dialog, setDialog] = useState<DialogState>(null);
201
+ const [permissionDialog, setPermissionDialog] = useState<PermissionDialogState>(null);
202
+ const [message, setMessage] = useState("");
203
+ const [collapsedAgentIds, setCollapsedAgentIds] = useState<ReadonlySet<string>>(() => new Set());
204
+
205
+ useEffect(() => {
206
+ const clock = setInterval(() => setNow(Date.now()), CLOCK_INTERVAL_MS);
207
+ return () => clearInterval(clock);
208
+ }, []);
209
+
210
+ useEffect(() => {
211
+ let debounce: ReturnType<typeof setTimeout> | undefined;
212
+ const invalidate = () => {
213
+ if (debounce) return;
214
+ debounce = setTimeout(() => {
215
+ debounce = undefined;
216
+ void queryClient.invalidateQueries({ queryKey });
217
+ }, REFRESH_DEBOUNCE_MS);
218
+ };
219
+ const unsubscribeAgents = paseo.agents.subscribe(invalidate);
220
+ const unsubscribeWorkspaces = paseo.workspaces.subscribe(invalidate);
221
+ return () => {
222
+ clearTimeout(debounce);
223
+ unsubscribeAgents();
224
+ unsubscribeWorkspaces();
225
+ };
226
+ }, [paseo, queryClient, queryKey]);
227
+
228
+ const allNodes = useMemo(
229
+ () =>
230
+ buildCrewForest(data?.entries ?? [], workspaceId, {
231
+ state: null,
232
+ query: "",
233
+ workspaceNames: data?.workspaceNames,
234
+ }),
235
+ [data, workspaceId],
236
+ );
237
+ const nodes = useMemo(
238
+ () =>
239
+ buildCrewForest(data?.entries ?? [], workspaceId, {
240
+ state: selectedState,
241
+ query,
242
+ workspaceNames: data?.workspaceNames,
243
+ }),
244
+ [data, query, selectedState, workspaceId],
245
+ );
246
+ const visibleNodes = useMemo(
247
+ () => collapseCrewNodes(nodes, collapsedAgentIds),
248
+ [collapsedAgentIds, nodes],
249
+ );
250
+ const counts = useMemo(() => crewCounts(allNodes), [allNodes]);
251
+ const memberNodes = useMemo(() => allNodes.filter(({ member }) => member), [allNodes]);
252
+ const crewCount = useMemo(
253
+ () =>
254
+ allNodes.filter(({ depth, descendantCount }) => depth === 0 && descendantCount > 0).length,
255
+ [allNodes],
256
+ );
257
+ const externalCount = useMemo(
258
+ () =>
259
+ memberNodes.filter(
260
+ ({ entry }) => entry.agent.workspaceId && entry.agent.workspaceId !== workspaceId,
261
+ ).length,
262
+ [memberNodes, workspaceId],
263
+ );
264
+
265
+ const action = useMutation({
266
+ mutationFn: async (input: CrewAction) => {
267
+ const handle = paseo.agents.ref(input.agentId);
268
+ if (input.kind === "send") {
269
+ await handle.send(input.text);
270
+ } else if (input.kind === "detach") {
271
+ await handle.detach();
272
+ } else {
273
+ await handle.archive();
274
+ }
275
+ },
276
+ onSuccess: (_result, input) => {
277
+ if (input.kind === "send") {
278
+ toast.show(input.interrupted ? "Agent redirected" : "Nudge sent", { variant: "success" });
279
+ } else if (input.kind === "detach") {
280
+ toast.show("Subagent detached", { variant: "success" });
281
+ } else {
282
+ toast.show("Subagent archived", { variant: "success" });
283
+ }
284
+ setDialog(null);
285
+ setMessage("");
286
+ },
287
+ onError: (mutationError) => {
288
+ toast.error(mutationError instanceof Error ? mutationError.message : "Agent action failed");
289
+ },
290
+ onSettled: async () => {
291
+ await queryClient.invalidateQueries({ queryKey });
292
+ },
293
+ });
294
+
295
+ const permissionAction = useMutation({
296
+ mutationFn: async (input: PermissionActionInput) => {
297
+ await paseo.agents.ref(input.agentId).respondToPermission({
298
+ requestId: input.request.id,
299
+ response:
300
+ input.behavior === "allow"
301
+ ? { behavior: "allow" }
302
+ : { behavior: "deny", message: "Denied from Agent Crew" },
303
+ });
304
+ },
305
+ onSuccess: (_result, input) => {
306
+ toast.show(input.behavior === "allow" ? "Permission allowed" : "Permission denied", {
307
+ variant: "success",
308
+ });
309
+ setPermissionDialog(null);
310
+ },
311
+ onError: (mutationError) => {
312
+ const message = permissionErrorMessage(mutationError);
313
+ toast.error(message);
314
+ if (message === "Permission request was already resolved.") {
315
+ setPermissionDialog(null);
316
+ }
317
+ },
318
+ onSettled: async () => {
319
+ await queryClient.invalidateQueries({ queryKey });
320
+ },
321
+ });
322
+
323
+ const panelStyles = useMemo(
324
+ () =>
325
+ StyleSheet.create({
326
+ screen: {
327
+ flex: 1,
328
+ backgroundColor: theme.colors.surface0,
329
+ },
330
+ header: {
331
+ paddingHorizontal: layout.compact ? 14 : 20,
332
+ paddingTop: layout.compact ? 14 : 18,
333
+ paddingBottom: 12,
334
+ gap: 10,
335
+ borderBottomWidth: StyleSheet.hairlineWidth,
336
+ borderBottomColor: theme.colors.border,
337
+ },
338
+ titleRow: {
339
+ flexDirection: "row",
340
+ alignItems: "center",
341
+ justifyContent: "space-between",
342
+ gap: 12,
343
+ },
344
+ titleBlock: { flex: 1, minWidth: 0 },
345
+ eyebrow: {
346
+ color: theme.colors.foregroundMuted,
347
+ fontSize: 11,
348
+ fontWeight: "600",
349
+ letterSpacing: 0.8,
350
+ textTransform: "uppercase",
351
+ },
352
+ title: {
353
+ color: theme.colors.foreground,
354
+ fontSize: layout.compact ? 19 : 22,
355
+ fontWeight: "700",
356
+ },
357
+ summary: {
358
+ color: theme.colors.foregroundMuted,
359
+ fontSize: 12,
360
+ },
361
+ toolbar: {
362
+ flexDirection: "row",
363
+ alignItems: "center",
364
+ gap: 8,
365
+ },
366
+ search: {
367
+ flex: 1,
368
+ minWidth: 120,
369
+ height: 36,
370
+ borderWidth: StyleSheet.hairlineWidth,
371
+ borderColor: theme.colors.border,
372
+ borderRadius: 8,
373
+ paddingHorizontal: 10,
374
+ color: theme.colors.foreground,
375
+ backgroundColor: theme.colors.surface1,
376
+ fontSize: 13,
377
+ },
378
+ refreshButton: {
379
+ minHeight: 36,
380
+ paddingHorizontal: 10,
381
+ borderWidth: StyleSheet.hairlineWidth,
382
+ borderColor: theme.colors.border,
383
+ borderRadius: 8,
384
+ alignItems: "center",
385
+ justifyContent: "center",
386
+ backgroundColor: theme.colors.surface1,
387
+ },
388
+ chipRail: { gap: 6 },
389
+ chip: {
390
+ minHeight: 30,
391
+ flexDirection: "row",
392
+ alignItems: "center",
393
+ gap: 6,
394
+ paddingHorizontal: 9,
395
+ borderRadius: 15,
396
+ borderWidth: StyleSheet.hairlineWidth,
397
+ borderColor: theme.colors.border,
398
+ backgroundColor: theme.colors.surface1,
399
+ },
400
+ chipSelected: {
401
+ borderColor: theme.colors.accent,
402
+ backgroundColor: theme.colors.surface2,
403
+ },
404
+ chipText: { color: theme.colors.foregroundMuted, fontSize: 12 },
405
+ chipTextSelected: { color: theme.colors.foreground, fontWeight: "600" },
406
+ dot: { width: 7, height: 7, borderRadius: 4 },
407
+ row: {
408
+ minHeight: layout.compact ? 78 : 68,
409
+ flexDirection: "row",
410
+ alignItems: "center",
411
+ gap: 10,
412
+ paddingVertical: 10,
413
+ paddingRight: layout.compact ? 10 : 16,
414
+ borderBottomWidth: StyleSheet.hairlineWidth,
415
+ borderBottomColor: theme.colors.border,
416
+ },
417
+ contextRow: { opacity: 0.58 },
418
+ treeRail: {
419
+ alignSelf: "stretch",
420
+ width: 10,
421
+ borderLeftWidth: 2,
422
+ borderLeftColor: theme.colors.border,
423
+ },
424
+ rowBody: { flex: 1, minWidth: 0, gap: 3 },
425
+ rowTitleLine: { flexDirection: "row", alignItems: "center", gap: 7 },
426
+ rowTitle: {
427
+ flexShrink: 1,
428
+ color: theme.colors.foreground,
429
+ fontSize: 14,
430
+ fontWeight: "600",
431
+ },
432
+ rootRow: { backgroundColor: theme.colors.surface1 },
433
+ childCount: { flexShrink: 0, color: theme.colors.foregroundMuted, fontSize: 11 },
434
+ collapseSpacer: { width: 30, height: 30 },
435
+ metadata: { color: theme.colors.foregroundMuted, fontSize: 11 },
436
+ lastError: { color: theme.colors.statusDanger, fontSize: 11 },
437
+ permissionCount: { color: theme.colors.statusWarning, fontSize: 11, fontWeight: "600" },
438
+ statusColumn: { alignItems: "flex-end", gap: 5 },
439
+ status: { fontSize: 11, fontWeight: "600" },
440
+ age: { color: theme.colors.foregroundMuted, fontSize: 10 },
441
+ rowActions: { flexDirection: "row", alignItems: "center", gap: 2 },
442
+ empty: { padding: 24, gap: 6, alignItems: "center" },
443
+ emptyTitle: { color: theme.colors.foreground, fontSize: 16, fontWeight: "600" },
444
+ emptyBody: { color: theme.colors.foregroundMuted, fontSize: 13, textAlign: "center" },
445
+ error: {
446
+ margin: 12,
447
+ padding: 10,
448
+ color: theme.colors.statusDanger,
449
+ backgroundColor: theme.colors.surface1,
450
+ borderRadius: 8,
451
+ },
452
+ contextBadge: {
453
+ color: theme.colors.foregroundMuted,
454
+ fontSize: 10,
455
+ fontWeight: "600",
456
+ textTransform: "uppercase",
457
+ },
458
+ truncated: { paddingHorizontal: 16, paddingVertical: 8, color: theme.colors.statusWarning },
459
+ modalBody: { gap: 14, padding: 18 },
460
+ modalCopy: { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 },
461
+ messageInput: {
462
+ minHeight: 112,
463
+ borderWidth: StyleSheet.hairlineWidth,
464
+ borderColor: theme.colors.border,
465
+ borderRadius: 8,
466
+ padding: 10,
467
+ color: theme.colors.foreground,
468
+ backgroundColor: theme.colors.surface1,
469
+ textAlignVertical: "top",
470
+ },
471
+ modalActions: { flexDirection: "row", justifyContent: "flex-end", gap: 8 },
472
+ secondaryButton: {
473
+ minHeight: 36,
474
+ justifyContent: "center",
475
+ paddingHorizontal: 12,
476
+ borderRadius: 8,
477
+ borderWidth: StyleSheet.hairlineWidth,
478
+ borderColor: theme.colors.border,
479
+ backgroundColor: theme.colors.surface1,
480
+ },
481
+ primaryButton: {
482
+ minHeight: 36,
483
+ justifyContent: "center",
484
+ paddingHorizontal: 12,
485
+ borderRadius: 8,
486
+ backgroundColor: theme.colors.accent,
487
+ },
488
+ dangerButton: { backgroundColor: theme.colors.statusDanger },
489
+ buttonText: { color: theme.colors.foreground, fontWeight: "600", fontSize: 13 },
490
+ primaryButtonText: {
491
+ color: theme.colors.accentForeground,
492
+ fontWeight: "600",
493
+ fontSize: 13,
494
+ },
495
+ permissionBody: { gap: 12 },
496
+ permissionSection: { gap: 4 },
497
+ permissionLabel: { color: theme.colors.foregroundMuted, fontSize: 11, fontWeight: "600" },
498
+ permissionValue: { color: theme.colors.foreground, fontSize: 13, lineHeight: 18 },
499
+ permissionJson: {
500
+ color: theme.colors.foreground,
501
+ fontSize: 12,
502
+ lineHeight: 17,
503
+ fontFamily: "monospace",
504
+ backgroundColor: theme.colors.surface1,
505
+ borderRadius: 8,
506
+ padding: 10,
507
+ },
508
+ permissionActions: { flexDirection: "row", justifyContent: "flex-end", gap: 8 },
509
+ permissionButton: {
510
+ minHeight: 36,
511
+ justifyContent: "center",
512
+ paddingHorizontal: 12,
513
+ borderRadius: 8,
514
+ borderWidth: StyleSheet.hairlineWidth,
515
+ borderColor: theme.colors.border,
516
+ backgroundColor: theme.colors.surface1,
517
+ },
518
+ permissionPrimary: { backgroundColor: theme.colors.accent },
519
+ permissionDeny: { backgroundColor: theme.colors.statusDanger },
520
+ permissionButtonText: { color: theme.colors.foreground, fontWeight: "600", fontSize: 13 },
521
+ permissionDenyText: { color: theme.colors.surface0, fontWeight: "600", fontSize: 13 },
522
+ permissionPrimaryText: {
523
+ color: theme.colors.accentForeground,
524
+ fontWeight: "600",
525
+ fontSize: 13,
526
+ },
527
+ }),
528
+ [layout.compact, theme],
529
+ );
530
+ function openDialog(kind: NonNullable<DialogState>["kind"], node: CrewNode) {
531
+ setMessage("");
532
+ setDialog({ kind, node });
533
+ }
534
+
535
+ function submitDialog() {
536
+ if (!dialog || action.isPending) return;
537
+ const agent = dialog.node.entry.agent;
538
+ if (dialog.kind === "message") {
539
+ const text = message.trim();
540
+ if (!text) return;
541
+ action.mutate({ kind: "send", agentId: agent.id, text, interrupted: isWorking(agent) });
542
+ return;
543
+ }
544
+ action.mutate({ kind: dialog.kind, agentId: agent.id });
545
+ }
546
+
547
+ function toggleCollapsed(agentId: string) {
548
+ setCollapsedAgentIds((current) => {
549
+ const next = new Set(current);
550
+ if (next.has(agentId)) next.delete(agentId);
551
+ else next.add(agentId);
552
+ return next;
553
+ });
554
+ }
555
+
556
+ function openPermissionDialog(node: CrewNode, request: PermissionRequest) {
557
+ setPermissionDialog({ node, request });
558
+ }
559
+
560
+ function submitPermissionAction(behavior: "allow" | "deny") {
561
+ if (!permissionDialog || permissionAction.isPending) return;
562
+ permissionAction.mutate({
563
+ agentId: permissionDialog.node.entry.agent.id,
564
+ request: permissionDialog.request,
565
+ behavior,
566
+ });
567
+ }
568
+
569
+ function renderRow({ item }: ListRenderItemInfo<CrewNode>) {
570
+ const agent = item.entry.agent;
571
+ const state = crewState(agent);
572
+ const color = stateColor(state, theme.colors);
573
+ const workspaceName = agent.workspaceId
574
+ ? data?.workspaceNames.get(agent.workspaceId)
575
+ : undefined;
576
+ const external = Boolean(agent.workspaceId && agent.workspaceId !== workspaceId);
577
+ const workspaceDetail = workspaceName
578
+ ? `${external ? "Elsewhere: " : ""}${workspaceName}`
579
+ : external
580
+ ? "Elsewhere"
581
+ : undefined;
582
+ const providerModel = agent.model ? `${agent.provider}/${agent.model}` : agent.provider;
583
+ const metadata = workspaceDetail ? `${providerModel} · ${workspaceDetail}` : providerModel;
584
+ const age = formatAge(agentAgeTimestamp(agent), now);
585
+ const expandable = item.descendantCount > 0;
586
+ const collapsed = collapsedAgentIds.has(agent.id);
587
+ const pendingPermissions = agent.pendingPermissions ?? [];
588
+ const pendingPermission = pendingPermissions[0];
589
+ const pendingPermissionCount = pendingPermissions.length;
590
+ const rowBody = (
591
+ <>
592
+ <View style={panelStyles.rowTitleLine}>
593
+ <View style={[panelStyles.dot, { backgroundColor: color }]} />
594
+ <Text style={panelStyles.rowTitle} numberOfLines={1}>
595
+ {agentTitle(item.entry)}
596
+ </Text>
597
+ {!item.member ? <Text style={panelStyles.contextBadge}>Context</Text> : null}
598
+ {expandable ? (
599
+ <Text style={panelStyles.childCount} numberOfLines={1}>
600
+ {item.descendantCount} {item.descendantCount === 1 ? "descendant" : "descendants"}
601
+ </Text>
602
+ ) : null}
603
+ </View>
604
+ <Text style={panelStyles.metadata} numberOfLines={1}>
605
+ {metadata}
606
+ </Text>
607
+ {agent.lastError ? (
608
+ <Text style={panelStyles.lastError} numberOfLines={1}>
609
+ {agent.lastError}
610
+ </Text>
611
+ ) : null}
612
+ </>
613
+ );
614
+
615
+ return (
616
+ <View
617
+ style={[
618
+ panelStyles.row,
619
+ item.depth === 0 && panelStyles.rootRow,
620
+ item.contextOnly && panelStyles.contextRow,
621
+ { paddingLeft: 12 + Math.min(item.depth, 6) * 16 },
622
+ ]}
623
+ >
624
+ {item.depth > 0 ? <View style={panelStyles.treeRail} /> : null}
625
+ {expandable ? (
626
+ <ActionButton
627
+ accessibilityLabel={`${collapsed ? "Expand" : "Collapse"} ${agentTitle(item.entry)}`}
628
+ color={theme.colors.foregroundMuted}
629
+ icon={collapsed ? "ChevronRight" : "ChevronDown"}
630
+ onPress={() => toggleCollapsed(agent.id)}
631
+ />
632
+ ) : (
633
+ <View style={panelStyles.collapseSpacer} />
634
+ )}
635
+ {navigation ? (
636
+ <Pressable
637
+ accessibilityRole="button"
638
+ accessibilityLabel={`Open ${agentTitle(item.entry)}`}
639
+ onPress={() => navigation.openAgent({ agentId: agent.id })}
640
+ style={({ pressed }) => [panelStyles.rowBody, pressed && styles.pressed]}
641
+ >
642
+ {rowBody}
643
+ </Pressable>
644
+ ) : (
645
+ <View style={panelStyles.rowBody}>{rowBody}</View>
646
+ )}
647
+ <View style={panelStyles.statusColumn}>
648
+ <Text
649
+ style={[
650
+ panelStyles.status,
651
+ { color: item.member ? color : theme.colors.foregroundMuted },
652
+ ]}
653
+ >
654
+ {item.member ? CREW_STATE_LABELS[state] : "Context"}
655
+ </Text>
656
+ {pendingPermissionCount > 0 ? (
657
+ <Text style={panelStyles.permissionCount} numberOfLines={1}>
658
+ {pendingPermissionCount} pending
659
+ </Text>
660
+ ) : null}
661
+ {age ? <Text style={panelStyles.age}>{age}</Text> : null}
662
+ {item.member ? (
663
+ <View style={panelStyles.rowActions}>
664
+ {pendingPermission ? (
665
+ <ActionButton
666
+ accessibilityLabel={`Review ${pendingPermissionCount} pending permission ${pendingPermissionCount === 1 ? "request" : "requests"} for ${agentTitle(item.entry)}`}
667
+ color={theme.colors.statusWarning}
668
+ disabled={permissionAction.isPending}
669
+ icon="Lock"
670
+ onPress={() => openPermissionDialog(item, pendingPermission)}
671
+ />
672
+ ) : null}
673
+ {agent.status !== "closed" ? (
674
+ <ActionButton
675
+ accessibilityLabel={`${isWorking(agent) ? "Interrupt and redirect" : "Nudge"} ${agentTitle(item.entry)}`}
676
+ color={
677
+ isWorking(agent) ? theme.colors.statusWarning : theme.colors.foregroundMuted
678
+ }
679
+ disabled={action.isPending}
680
+ icon={isWorking(agent) ? "CornerDownRight" : "MessageSquareMore"}
681
+ onPress={() => openDialog("message", item)}
682
+ />
683
+ ) : null}
684
+ {parentAgentId(agent) ? (
685
+ <ActionButton
686
+ accessibilityLabel={`Detach ${agentTitle(item.entry)}`}
687
+ color={theme.colors.foregroundMuted}
688
+ disabled={action.isPending}
689
+ icon="Unlink"
690
+ onPress={() => openDialog("detach", item)}
691
+ />
692
+ ) : null}
693
+ <ActionButton
694
+ accessibilityLabel={`Archive ${agentTitle(item.entry)}`}
695
+ color={theme.colors.foregroundMuted}
696
+ disabled={action.isPending}
697
+ icon="Archive"
698
+ onPress={() => openDialog("archive", item)}
699
+ />
700
+ </View>
701
+ ) : null}
702
+ </View>
703
+ </View>
704
+ );
705
+ }
706
+ const currentWorkspaceTitle = workspaceTitle?.trim() || "Current workspace";
707
+ let dialogTitle = "";
708
+ let dialogCopy = "";
709
+ let confirmLabel = "";
710
+ if (dialog) {
711
+ const target = agentTitle(dialog.node.entry);
712
+ if (dialog.kind === "message") {
713
+ const state = crewState(dialog.node.entry.agent);
714
+ const running = isWorking(dialog.node.entry.agent);
715
+ dialogTitle = running ? `Interrupt and redirect ${target}` : `Nudge ${target}`;
716
+ dialogCopy = running
717
+ ? "This agent is working. Sending a message stops its current turn and starts the new direction."
718
+ : state === "needs-input"
719
+ ? "This agent is waiting for a permission decision. Sending a message dismisses that request and starts the new direction."
720
+ : "Send a concise follow-up with the missing context or next step.";
721
+ confirmLabel = running
722
+ ? "Interrupt & redirect"
723
+ : state === "needs-input"
724
+ ? "Dismiss request & nudge"
725
+ : "Send nudge";
726
+ } else if (dialog.kind === "detach") {
727
+ dialogTitle = `Detach ${target}?`;
728
+ dialogCopy =
729
+ dialog.node.descendantCount > 0
730
+ ? `This agent and its ${dialog.node.descendantCount} descendants will leave this crew view and continue as standalone agents.`
731
+ : "This agent will leave this crew view and continue as a standalone agent.";
732
+ confirmLabel = "Detach";
733
+ } else {
734
+ dialogTitle = `Archive ${target}?`;
735
+ dialogCopy =
736
+ dialog.node.descendantCount > 0
737
+ ? `This agent has ${dialog.node.descendantCount} managed descendants. Same-workspace descendants are archived with it; cross-workspace descendants detach and continue.`
738
+ : isWorking(dialog.node.entry.agent)
739
+ ? "This agent is still working. Archiving stops it and removes it from the crew."
740
+ : "This agent will stop and be removed from the crew.";
741
+ confirmLabel = "Archive";
742
+ }
743
+ }
744
+ const permissionDialogTitle = permissionDialog
745
+ ? `Permission request from ${agentTitle(permissionDialog.node.entry)}`
746
+ : "Permission request";
747
+
748
+ return (
749
+ <View style={panelStyles.screen}>
750
+ <View style={panelStyles.header}>
751
+ <View style={panelStyles.titleRow}>
752
+ <View style={panelStyles.titleBlock}>
753
+ <Text style={panelStyles.eyebrow}>Agent Crew</Text>
754
+ <Text style={panelStyles.title} numberOfLines={1}>
755
+ {currentWorkspaceTitle}
756
+ </Text>
757
+ <Text style={panelStyles.summary}>
758
+ {memberNodes.length} {memberNodes.length === 1 ? "agent" : "agents"} · {crewCount}{" "}
759
+ {crewCount === 1 ? "crew" : "crews"}
760
+ {externalCount > 0 ? ` · ${externalCount} elsewhere` : ""}
761
+ </Text>
762
+ </View>
763
+ <Pressable
764
+ accessibilityRole="button"
765
+ accessibilityLabel="Refresh Agent Crew"
766
+ onPress={() => void refetch()}
767
+ style={({ pressed }) => [panelStyles.refreshButton, pressed && styles.pressed]}
768
+ >
769
+ <Icon
770
+ name="RefreshCw"
771
+ size={16}
772
+ color={isFetching ? theme.colors.accent : theme.colors.foregroundMuted}
773
+ />
774
+ </Pressable>
775
+ </View>
776
+ <View style={panelStyles.toolbar}>
777
+ <TextInput
778
+ accessibilityLabel="Filter agents"
779
+ autoCapitalize="none"
780
+ autoCorrect={false}
781
+ onChangeText={setQuery}
782
+ placeholder="Filter by task, model, workspace"
783
+ placeholderTextColor={theme.colors.foregroundMuted}
784
+ value={query}
785
+ style={panelStyles.search}
786
+ />
787
+ </View>
788
+ <ScrollView
789
+ horizontal
790
+ showsHorizontalScrollIndicator={false}
791
+ contentContainerStyle={panelStyles.chipRail}
792
+ >
793
+ <Pressable
794
+ accessibilityRole="button"
795
+ accessibilityState={{ selected: selectedState === null }}
796
+ onPress={() => setSelectedState(null)}
797
+ style={[panelStyles.chip, selectedState === null && panelStyles.chipSelected]}
798
+ >
799
+ <Text
800
+ style={[panelStyles.chipText, selectedState === null && panelStyles.chipTextSelected]}
801
+ >
802
+ All {memberNodes.length}
803
+ </Text>
804
+ </Pressable>
805
+ {CREW_STATES.map((state) => (
806
+ <Pressable
807
+ key={state}
808
+ accessibilityRole="button"
809
+ accessibilityState={{ selected: selectedState === state }}
810
+ onPress={() => setSelectedState(state)}
811
+ style={[panelStyles.chip, selectedState === state && panelStyles.chipSelected]}
812
+ >
813
+ <View
814
+ style={[panelStyles.dot, { backgroundColor: stateColor(state, theme.colors) }]}
815
+ />
816
+ <Text
817
+ style={[
818
+ panelStyles.chipText,
819
+ selectedState === state && panelStyles.chipTextSelected,
820
+ ]}
821
+ >
822
+ {CREW_STATE_LABELS[state]} {counts[state]}
823
+ </Text>
824
+ </Pressable>
825
+ ))}
826
+ </ScrollView>
827
+ </View>
828
+
829
+ {data?.truncated ? (
830
+ <Text style={panelStyles.truncated}>
831
+ Showing the first {PAGE_LIMIT * MAX_PAGES} daemon agents. Some agents in this workspace or
832
+ their crew may be missing.
833
+ </Text>
834
+ ) : null}
835
+ {error ? (
836
+ <Text style={panelStyles.error}>
837
+ {error instanceof Error ? error.message : "Could not load agents"}
838
+ </Text>
839
+ ) : null}
840
+ <FlatList
841
+ data={visibleNodes}
842
+ keyExtractor={(node) => node.entry.agent.id}
843
+ renderItem={renderRow}
844
+ keyboardShouldPersistTaps="handled"
845
+ ListEmptyComponent={
846
+ <View style={panelStyles.empty}>
847
+ <Icon name="Network" size={24} color={theme.colors.foregroundMuted} />
848
+ <Text style={panelStyles.emptyTitle}>
849
+ {isPending
850
+ ? "Loading crew"
851
+ : memberNodes.length === 0
852
+ ? "No agents yet"
853
+ : "No matches"}
854
+ </Text>
855
+ <Text style={panelStyles.emptyBody}>
856
+ {isPending
857
+ ? "Reading this workspace’s agents."
858
+ : memberNodes.length === 0
859
+ ? "Start an agent or ask one to delegate. Crews will appear here."
860
+ : "Change the status filter or search text."}
861
+ </Text>
862
+ </View>
863
+ }
864
+ />
865
+
866
+ <Modal
867
+ title={dialogTitle}
868
+ open={dialog !== null}
869
+ onOpenChange={(open) => {
870
+ if (!open && !action.isPending) {
871
+ setDialog(null);
872
+ setMessage("");
873
+ }
874
+ }}
875
+ icon={
876
+ dialog ? (
877
+ <Icon
878
+ name={
879
+ dialog.kind === "message"
880
+ ? "MessageSquareMore"
881
+ : dialog.kind === "detach"
882
+ ? "Unlink"
883
+ : "Archive"
884
+ }
885
+ size={18}
886
+ color={
887
+ dialog.kind === "archive" ? theme.colors.statusDanger : theme.colors.foreground
888
+ }
889
+ />
890
+ ) : undefined
891
+ }
892
+ >
893
+ <Modal.Content>
894
+ <View style={panelStyles.modalBody}>
895
+ <Text style={panelStyles.modalCopy}>{dialogCopy}</Text>
896
+ {dialog?.kind === "message" ? (
897
+ <TextInput
898
+ accessibilityLabel="Message to subagent"
899
+ autoFocus
900
+ multiline
901
+ onChangeText={setMessage}
902
+ placeholder="What should this agent do next?"
903
+ placeholderTextColor={theme.colors.foregroundMuted}
904
+ value={message}
905
+ style={panelStyles.messageInput}
906
+ />
907
+ ) : null}
908
+ <View style={panelStyles.modalActions}>
909
+ <Pressable
910
+ accessibilityRole="button"
911
+ disabled={action.isPending}
912
+ onPress={() => {
913
+ setDialog(null);
914
+ setMessage("");
915
+ }}
916
+ style={({ pressed }) => [panelStyles.secondaryButton, pressed && styles.pressed]}
917
+ >
918
+ <Text style={panelStyles.buttonText}>Cancel</Text>
919
+ </Pressable>
920
+ <Pressable
921
+ accessibilityRole="button"
922
+ disabled={action.isPending || (dialog?.kind === "message" && !message.trim())}
923
+ onPress={submitDialog}
924
+ style={({ pressed }) => [
925
+ panelStyles.primaryButton,
926
+ dialog?.kind === "archive" && panelStyles.dangerButton,
927
+ pressed && styles.pressed,
928
+ (action.isPending || (dialog?.kind === "message" && !message.trim())) &&
929
+ styles.disabled,
930
+ ]}
931
+ >
932
+ <Text style={panelStyles.primaryButtonText}>
933
+ {action.isPending ? "Working…" : confirmLabel}
934
+ </Text>
935
+ </Pressable>
936
+ </View>
937
+ </View>
938
+ </Modal.Content>
939
+ </Modal>
940
+
941
+ <Modal
942
+ title={permissionDialogTitle}
943
+ open={permissionDialog !== null}
944
+ onOpenChange={(open) => {
945
+ if (!open && !permissionAction.isPending) {
946
+ setPermissionDialog(null);
947
+ }
948
+ }}
949
+ icon={
950
+ permissionDialog ? (
951
+ <Icon name="Lock" size={18} color={theme.colors.foreground} />
952
+ ) : undefined
953
+ }
954
+ >
955
+ <Modal.Content contentContainerStyle={panelStyles.permissionBody}>
956
+ {permissionDialog ? (
957
+ <>
958
+ <Text style={panelStyles.modalCopy}>
959
+ Review the pending permission request below. Allow or deny it explicitly.
960
+ </Text>
961
+ <View style={panelStyles.permissionSection}>
962
+ <Text style={panelStyles.permissionLabel}>Request</Text>
963
+ <Text style={panelStyles.permissionValue}>
964
+ {permissionRequestText(permissionDialog.request)}
965
+ </Text>
966
+ </View>
967
+ <View style={panelStyles.permissionSection}>
968
+ <Text style={panelStyles.permissionLabel}>Kind</Text>
969
+ <Text style={panelStyles.permissionValue}>{permissionDialog.request.kind}</Text>
970
+ </View>
971
+ {permissionDialog.request.description ? (
972
+ <View style={panelStyles.permissionSection}>
973
+ <Text style={panelStyles.permissionLabel}>Description</Text>
974
+ <Text style={panelStyles.permissionValue}>
975
+ {permissionDialog.request.description}
976
+ </Text>
977
+ </View>
978
+ ) : null}
979
+ <View style={panelStyles.permissionSection}>
980
+ <Text style={panelStyles.permissionLabel}>Payload</Text>
981
+ <Text selectable style={panelStyles.permissionJson}>
982
+ {permissionRequestDetails(permissionDialog.request)}
983
+ </Text>
984
+ </View>
985
+ <View style={panelStyles.permissionActions}>
986
+ <Pressable
987
+ accessibilityRole="button"
988
+ disabled={permissionAction.isPending}
989
+ onPress={() => setPermissionDialog(null)}
990
+ style={({ pressed }) => [panelStyles.permissionButton, pressed && styles.pressed]}
991
+ >
992
+ <Text style={panelStyles.permissionButtonText}>Cancel</Text>
993
+ </Pressable>
994
+ <Pressable
995
+ accessibilityRole="button"
996
+ disabled={permissionAction.isPending}
997
+ onPress={() => submitPermissionAction("deny")}
998
+ style={({ pressed }) => [
999
+ panelStyles.permissionButton,
1000
+ panelStyles.permissionDeny,
1001
+ pressed && styles.pressed,
1002
+ permissionAction.isPending && styles.disabled,
1003
+ ]}
1004
+ >
1005
+ <Text style={panelStyles.permissionDenyText}>
1006
+ {permissionAction.isPending ? "Working…" : "Deny"}
1007
+ </Text>
1008
+ </Pressable>
1009
+ <Pressable
1010
+ accessibilityRole="button"
1011
+ disabled={permissionAction.isPending}
1012
+ onPress={() => submitPermissionAction("allow")}
1013
+ style={({ pressed }) => [
1014
+ panelStyles.permissionButton,
1015
+ panelStyles.permissionPrimary,
1016
+ pressed && styles.pressed,
1017
+ permissionAction.isPending && styles.disabled,
1018
+ ]}
1019
+ >
1020
+ <Text style={panelStyles.permissionPrimaryText}>
1021
+ {permissionAction.isPending ? "Working…" : "Allow"}
1022
+ </Text>
1023
+ </Pressable>
1024
+ </View>
1025
+ </>
1026
+ ) : null}
1027
+ </Modal.Content>
1028
+ </Modal>
1029
+ </View>
1030
+ );
1031
+ }
1032
+
1033
+ const styles = StyleSheet.create({
1034
+ actionButton: {
1035
+ width: 30,
1036
+ height: 30,
1037
+ alignItems: "center",
1038
+ justifyContent: "center",
1039
+ borderRadius: 7,
1040
+ },
1041
+ pressed: { opacity: 0.58 },
1042
+ disabled: { opacity: 0.38 },
1043
+ });