openclaw-node-harness 2.0.3 → 2.1.0

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.
Files changed (118) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +603 -81
  4. package/bin/mesh-bridge.js +340 -11
  5. package/bin/mesh-deploy-listener.js +119 -97
  6. package/bin/mesh-deploy.js +8 -0
  7. package/bin/mesh-task-daemon.js +1005 -40
  8. package/bin/mesh.js +423 -6
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +300 -8
  29. package/lib/circling-parser.js +119 -0
  30. package/lib/hyperagent-store.mjs +652 -0
  31. package/lib/kanban-io.js +59 -10
  32. package/lib/mcp-knowledge/bench.mjs +118 -0
  33. package/lib/mcp-knowledge/core.mjs +528 -0
  34. package/lib/mcp-knowledge/package.json +25 -0
  35. package/lib/mcp-knowledge/server.mjs +245 -0
  36. package/lib/mcp-knowledge/test.mjs +802 -0
  37. package/lib/memory-budget.mjs +261 -0
  38. package/lib/mesh-collab.js +354 -4
  39. package/lib/mesh-harness.js +427 -0
  40. package/lib/mesh-plans.js +13 -5
  41. package/lib/mesh-registry.js +11 -2
  42. package/lib/mesh-tasks.js +67 -0
  43. package/lib/plan-templates.js +226 -0
  44. package/lib/pre-compression-flush.mjs +320 -0
  45. package/lib/role-loader.js +292 -0
  46. package/lib/rule-loader.js +358 -0
  47. package/lib/session-store.mjs +458 -0
  48. package/lib/transcript-parser.mjs +292 -0
  49. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  50. package/mission-control/drizzle.config.ts +1 -4
  51. package/mission-control/package-lock.json +1571 -83
  52. package/mission-control/package.json +6 -2
  53. package/mission-control/scripts/gen-chronology.js +3 -3
  54. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  55. package/mission-control/scripts/import-pipeline.js +0 -15
  56. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  57. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  58. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  59. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  60. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  61. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  62. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  63. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  64. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  65. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  66. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  67. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  68. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  69. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  70. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  71. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  72. package/mission-control/src/app/api/tasks/route.ts +21 -30
  73. package/mission-control/src/app/cowork/page.tsx +261 -0
  74. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  75. package/mission-control/src/app/graph/page.tsx +26 -0
  76. package/mission-control/src/app/memory/page.tsx +1 -1
  77. package/mission-control/src/app/obsidian/page.tsx +36 -6
  78. package/mission-control/src/app/roadmap/page.tsx +24 -0
  79. package/mission-control/src/app/souls/page.tsx +2 -2
  80. package/mission-control/src/components/board/execution-config.tsx +431 -0
  81. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  82. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  83. package/mission-control/src/components/board/task-card.tsx +55 -2
  84. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  85. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  86. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  87. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  88. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  89. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  90. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  91. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  92. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  93. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  94. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  95. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  96. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  97. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  98. package/mission-control/src/lib/config.ts +58 -0
  99. package/mission-control/src/lib/db/index.ts +69 -0
  100. package/mission-control/src/lib/db/schema.ts +61 -3
  101. package/mission-control/src/lib/hooks.ts +309 -0
  102. package/mission-control/src/lib/memory/entities.ts +3 -2
  103. package/mission-control/src/lib/nats.ts +66 -1
  104. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  105. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  106. package/mission-control/src/lib/scheduler.ts +12 -11
  107. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  108. package/mission-control/src/lib/sync/tasks.ts +23 -1
  109. package/mission-control/src/lib/task-id.ts +32 -0
  110. package/mission-control/src/lib/tts/index.ts +33 -9
  111. package/mission-control/tsconfig.json +2 -1
  112. package/mission-control/vitest.config.ts +14 -0
  113. package/package.json +15 -2
  114. package/services/service-manifest.json +1 -1
  115. package/skills/cc-godmode/references/agents.md +8 -8
  116. package/workspace-bin/memory-daemon.mjs +199 -5
  117. package/workspace-bin/session-search.mjs +204 -0
  118. package/workspace-bin/web-fetch.mjs +65 -0
@@ -0,0 +1,251 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { X } from "lucide-react";
5
+ import { createCluster } from "@/lib/hooks";
6
+ import { RolePicker } from "./role-picker";
7
+ import useSWR from "swr";
8
+
9
+ const fetcher = (url: string) => fetch(url).then((r) => r.json());
10
+
11
+ const PRESET_COLORS = [
12
+ "#6366f1", "#8b5cf6", "#06b6d4", "#10b981",
13
+ "#f59e0b", "#ef4444", "#ec4899", "#64748b",
14
+ ];
15
+
16
+ const MODES = ["parallel", "sequential", "review"] as const;
17
+ const CONVERGENCES = ["unanimous", "majority", "coordinator"] as const;
18
+
19
+ interface Props {
20
+ open: boolean;
21
+ onClose: () => void;
22
+ }
23
+
24
+ export function CreateClusterDialog({ open, onClose }: Props) {
25
+ const [name, setName] = useState("");
26
+ const [description, setDescription] = useState("");
27
+ const [color, setColor] = useState(PRESET_COLORS[0]);
28
+ const [mode, setMode] = useState<string>("parallel");
29
+ const [convergence, setConvergence] = useState<string>("unanimous");
30
+ const [maxRounds, setMaxRounds] = useState(5);
31
+ const [selectedNodes, setSelectedNodes] = useState<
32
+ Array<{ nodeId: string; role: string }>
33
+ >([]);
34
+ const [saving, setSaving] = useState(false);
35
+
36
+ const { data } = useSWR<{ nodes: Array<{ nodeId: string; status: string }> }>(
37
+ "/api/mesh/nodes",
38
+ fetcher
39
+ );
40
+ const meshNodes = data?.nodes ?? [];
41
+
42
+ const toggleNode = (nodeId: string) => {
43
+ setSelectedNodes((prev) => {
44
+ const exists = prev.find((n) => n.nodeId === nodeId);
45
+ if (exists) return prev.filter((n) => n.nodeId !== nodeId);
46
+ return [...prev, { nodeId, role: "worker" }];
47
+ });
48
+ };
49
+
50
+ const updateNodeRole = (nodeId: string, role: string) => {
51
+ setSelectedNodes((prev) =>
52
+ prev.map((n) => (n.nodeId === nodeId ? { ...n, role } : n))
53
+ );
54
+ };
55
+
56
+ const handleSave = async () => {
57
+ if (!name.trim()) return;
58
+ setSaving(true);
59
+ try {
60
+ await createCluster({
61
+ name: name.trim(),
62
+ description: description.trim() || undefined,
63
+ color,
64
+ defaultMode: mode,
65
+ defaultConvergence: convergence,
66
+ maxRounds,
67
+ members: selectedNodes,
68
+ });
69
+ onClose();
70
+ setName("");
71
+ setDescription("");
72
+ setSelectedNodes([]);
73
+ } finally {
74
+ setSaving(false);
75
+ }
76
+ };
77
+
78
+ if (!open) return null;
79
+
80
+ return (
81
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
82
+ <div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
83
+ {/* Header */}
84
+ <div className="flex items-center justify-between border-b border-border px-4 py-3">
85
+ <span className="text-sm font-semibold">New Cluster</span>
86
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground">
87
+ <X className="h-4 w-4" />
88
+ </button>
89
+ </div>
90
+
91
+ <div className="space-y-4 px-4 py-4">
92
+ {/* Name */}
93
+ <input
94
+ type="text"
95
+ placeholder="Cluster name..."
96
+ value={name}
97
+ onChange={(e) => setName(e.target.value)}
98
+ className="w-full rounded border border-border bg-transparent px-3 py-2 text-sm outline-none focus:border-accent-foreground"
99
+ />
100
+
101
+ {/* Description */}
102
+ <textarea
103
+ placeholder="Description (optional)"
104
+ value={description}
105
+ onChange={(e) => setDescription(e.target.value)}
106
+ rows={2}
107
+ className="w-full rounded border border-border bg-transparent px-3 py-2 text-xs outline-none focus:border-accent-foreground resize-none"
108
+ />
109
+
110
+ {/* Color */}
111
+ <div className="flex items-center gap-2">
112
+ <span className="text-xs text-muted-foreground w-12">Color</span>
113
+ <div className="flex gap-2">
114
+ {PRESET_COLORS.map((c) => (
115
+ <button
116
+ key={c}
117
+ onClick={() => setColor(c)}
118
+ className={`h-5 w-5 rounded-full border-2 transition-colors ${
119
+ color === c ? "border-white" : "border-transparent"
120
+ }`}
121
+ style={{ backgroundColor: c }}
122
+ />
123
+ ))}
124
+ </div>
125
+ </div>
126
+
127
+ {/* Mode */}
128
+ <div className="flex items-center gap-2">
129
+ <span className="text-xs text-muted-foreground w-12">Mode</span>
130
+ <div className="flex gap-1">
131
+ {MODES.map((m) => (
132
+ <button
133
+ key={m}
134
+ onClick={() => setMode(m)}
135
+ className={`rounded px-2 py-1 text-xs transition-colors ${
136
+ mode === m
137
+ ? "bg-accent text-accent-foreground"
138
+ : "text-muted-foreground hover:bg-accent/50"
139
+ }`}
140
+ >
141
+ {m}
142
+ </button>
143
+ ))}
144
+ </div>
145
+ </div>
146
+
147
+ {/* Convergence */}
148
+ <div className="flex items-center gap-2">
149
+ <span className="text-xs text-muted-foreground w-12">Conv.</span>
150
+ <div className="flex gap-1">
151
+ {CONVERGENCES.map((c) => (
152
+ <button
153
+ key={c}
154
+ onClick={() => setConvergence(c)}
155
+ className={`rounded px-2 py-1 text-xs transition-colors ${
156
+ convergence === c
157
+ ? "bg-accent text-accent-foreground"
158
+ : "text-muted-foreground hover:bg-accent/50"
159
+ }`}
160
+ >
161
+ {c}
162
+ </button>
163
+ ))}
164
+ </div>
165
+ </div>
166
+
167
+ {/* Max rounds */}
168
+ <div className="flex items-center gap-2">
169
+ <span className="text-xs text-muted-foreground w-12">Rounds</span>
170
+ <input
171
+ type="number"
172
+ min={1}
173
+ max={20}
174
+ value={maxRounds}
175
+ onChange={(e) => setMaxRounds(Number(e.target.value))}
176
+ className="w-16 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
177
+ />
178
+ </div>
179
+
180
+ {/* Node picker */}
181
+ <div>
182
+ <span className="text-xs text-muted-foreground">Nodes</span>
183
+ <div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
184
+ {meshNodes.map((node) => {
185
+ const selected = selectedNodes.find(
186
+ (n) => n.nodeId === node.nodeId
187
+ );
188
+ return (
189
+ <div
190
+ key={node.nodeId}
191
+ className={`flex items-center justify-between rounded px-3 py-2 text-xs cursor-pointer transition-colors ${
192
+ selected
193
+ ? "bg-accent/40 border border-accent-foreground/20"
194
+ : "bg-accent/10 hover:bg-accent/20"
195
+ }`}
196
+ onClick={() => toggleNode(node.nodeId)}
197
+ >
198
+ <div className="flex items-center gap-2">
199
+ <span
200
+ className={`h-2 w-2 rounded-full ${
201
+ node.status === "online"
202
+ ? "bg-green-400"
203
+ : node.status === "degraded"
204
+ ? "bg-yellow-400"
205
+ : "bg-zinc-600"
206
+ }`}
207
+ />
208
+ <span className="font-mono">{node.nodeId}</span>
209
+ </div>
210
+ {selected && (
211
+ <div onClick={(e) => e.stopPropagation()}>
212
+ <RolePicker
213
+ value={selected.role}
214
+ onChange={(role) =>
215
+ updateNodeRole(node.nodeId, role)
216
+ }
217
+ />
218
+ </div>
219
+ )}
220
+ </div>
221
+ );
222
+ })}
223
+ {meshNodes.length === 0 && (
224
+ <p className="text-xs text-muted-foreground py-2">
225
+ No mesh nodes available
226
+ </p>
227
+ )}
228
+ </div>
229
+ </div>
230
+ </div>
231
+
232
+ {/* Footer */}
233
+ <div className="flex justify-end gap-2 border-t border-border px-4 py-3">
234
+ <button
235
+ onClick={onClose}
236
+ className="rounded px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
237
+ >
238
+ Cancel
239
+ </button>
240
+ <button
241
+ onClick={handleSave}
242
+ disabled={!name.trim() || saving}
243
+ className="rounded bg-accent-foreground px-3 py-1.5 text-xs text-accent disabled:opacity-50 transition-colors"
244
+ >
245
+ {saving ? "Creating..." : "Create Cluster"}
246
+ </button>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ );
251
+ }
@@ -0,0 +1,423 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import useSWR from "swr";
5
+ import {
6
+ dispatchCollabTask,
7
+ useClusters,
8
+ type ClusterView,
9
+ } from "@/lib/hooks";
10
+ import { RolePicker, RoleBadge } from "./role-picker";
11
+
12
+ const fetcher = (url: string) => fetch(url).then((r) => r.json());
13
+
14
+ const MODES = ["parallel", "sequential", "review"] as const;
15
+ const CONVERGENCES = ["unanimous", "majority", "coordinator"] as const;
16
+ const SCOPE_STRATEGIES = ["shared", "leader_only", "partitioned"] as const;
17
+
18
+ export function DispatchForm() {
19
+ const { clusters } = useClusters();
20
+ const { data: nodesData } = useSWR<{
21
+ nodes: Array<{ nodeId: string; status: string }>;
22
+ }>("/api/mesh/nodes", fetcher);
23
+ const meshNodes = nodesData?.nodes ?? [];
24
+
25
+ // Form state
26
+ const [title, setTitle] = useState("");
27
+ const [description, setDescription] = useState("");
28
+ const [nodeSource, setNodeSource] = useState<"cluster" | "manual">(
29
+ "cluster"
30
+ );
31
+ const [selectedClusterId, setSelectedClusterId] = useState<string | null>(
32
+ null
33
+ );
34
+ const [manualNodes, setManualNodes] = useState<
35
+ Array<{ nodeId: string; role: string }>
36
+ >([]);
37
+ const [mode, setMode] = useState<string>("parallel");
38
+ const [convergenceType, setConvergenceType] = useState<string>("unanimous");
39
+ const [threshold, setThreshold] = useState(66);
40
+ const [scopeStrategy, setScopeStrategy] = useState<string>("shared");
41
+ const [maxRounds, setMaxRounds] = useState(5);
42
+ const [budgetMinutes, setBudgetMinutes] = useState(30);
43
+ const [metric, setMetric] = useState("");
44
+ const [scopeText, setScopeText] = useState("");
45
+ const [submitting, setSubmitting] = useState(false);
46
+ const [result, setResult] = useState<{
47
+ ok: boolean;
48
+ message: string;
49
+ taskId: string | null;
50
+ } | null>(null);
51
+
52
+ const selectedCluster = clusters.find((c) => c.id === selectedClusterId);
53
+
54
+ const toggleManualNode = (nodeId: string) => {
55
+ setManualNodes((prev) => {
56
+ const exists = prev.find((n) => n.nodeId === nodeId);
57
+ if (exists) return prev.filter((n) => n.nodeId !== nodeId);
58
+ return [...prev, { nodeId, role: "worker" }];
59
+ });
60
+ };
61
+
62
+ const handleSubmit = async () => {
63
+ if (!title.trim()) return;
64
+ setSubmitting(true);
65
+ setResult(null);
66
+
67
+ try {
68
+ const scope = scopeText
69
+ .split("\n")
70
+ .map((s) => s.trim())
71
+ .filter(Boolean);
72
+
73
+ const data = await dispatchCollabTask({
74
+ title: title.trim(),
75
+ description: description.trim() || undefined,
76
+ clusterId:
77
+ nodeSource === "cluster" ? selectedClusterId ?? undefined : undefined,
78
+ nodes: nodeSource === "manual" ? manualNodes : undefined,
79
+ mode,
80
+ convergence: {
81
+ type: convergenceType,
82
+ threshold: convergenceType === "majority" ? threshold : undefined,
83
+ },
84
+ scopeStrategy,
85
+ budgetMinutes,
86
+ maxRounds,
87
+ metric: metric.trim() || undefined,
88
+ scope: scope.length > 0 ? scope : undefined,
89
+ });
90
+
91
+ if (data.error) {
92
+ setResult({ ok: false, message: data.error, taskId: null });
93
+ } else {
94
+ setResult({
95
+ ok: true,
96
+ message: `Task ${data.taskId} dispatched to ${data.nodesAssigned?.length ?? 0} nodes`,
97
+ taskId: data.taskId,
98
+ });
99
+ // Reset form
100
+ setTitle("");
101
+ setDescription("");
102
+ setMetric("");
103
+ setScopeText("");
104
+ }
105
+ } catch (err) {
106
+ setResult({ ok: false, message: (err as Error).message, taskId: null });
107
+ } finally {
108
+ setSubmitting(false);
109
+ }
110
+ };
111
+
112
+ return (
113
+ <div className="max-w-2xl space-y-5">
114
+ {/* Title */}
115
+ <div>
116
+ <label className="text-xs text-muted-foreground">Title *</label>
117
+ <input
118
+ type="text"
119
+ value={title}
120
+ onChange={(e) => setTitle(e.target.value)}
121
+ placeholder="Collab task title..."
122
+ className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-sm outline-none focus:border-accent-foreground"
123
+ />
124
+ </div>
125
+
126
+ {/* Description */}
127
+ <div>
128
+ <label className="text-xs text-muted-foreground">Description</label>
129
+ <textarea
130
+ value={description}
131
+ onChange={(e) => setDescription(e.target.value)}
132
+ placeholder="What should the cluster work on?"
133
+ rows={3}
134
+ className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-xs outline-none focus:border-accent-foreground resize-none"
135
+ />
136
+ </div>
137
+
138
+ {/* Node source toggle */}
139
+ <div>
140
+ <label className="text-xs text-muted-foreground">Nodes</label>
141
+ <div className="mt-1 flex gap-1">
142
+ <button
143
+ onClick={() => setNodeSource("cluster")}
144
+ className={`rounded px-3 py-1.5 text-xs transition-colors ${
145
+ nodeSource === "cluster"
146
+ ? "bg-accent text-accent-foreground"
147
+ : "text-muted-foreground hover:bg-accent/50"
148
+ }`}
149
+ >
150
+ From Cluster
151
+ </button>
152
+ <button
153
+ onClick={() => setNodeSource("manual")}
154
+ className={`rounded px-3 py-1.5 text-xs transition-colors ${
155
+ nodeSource === "manual"
156
+ ? "bg-accent text-accent-foreground"
157
+ : "text-muted-foreground hover:bg-accent/50"
158
+ }`}
159
+ >
160
+ Manual
161
+ </button>
162
+ </div>
163
+
164
+ {nodeSource === "cluster" && (
165
+ <div className="mt-2 space-y-2">
166
+ {clusters.map((c) => (
167
+ <div
168
+ key={c.id}
169
+ onClick={() => setSelectedClusterId(c.id)}
170
+ className={`flex items-center gap-3 rounded px-3 py-2 text-xs cursor-pointer transition-colors ${
171
+ selectedClusterId === c.id
172
+ ? "bg-accent/40 border border-accent-foreground/20"
173
+ : "bg-accent/10 hover:bg-accent/20"
174
+ }`}
175
+ >
176
+ {c.color && (
177
+ <span
178
+ className="h-2.5 w-2.5 rounded-full"
179
+ style={{ backgroundColor: c.color }}
180
+ />
181
+ )}
182
+ <span className="font-medium">{c.name}</span>
183
+ <span className="text-muted-foreground">
184
+ {c.members.length} nodes
185
+ </span>
186
+ </div>
187
+ ))}
188
+ {selectedCluster && (
189
+ <div className="flex flex-wrap gap-1.5 mt-2">
190
+ {selectedCluster.members.map((m) => (
191
+ <span
192
+ key={m.nodeId}
193
+ className="inline-flex items-center gap-1 rounded bg-accent/30 px-2 py-0.5 text-[10px]"
194
+ >
195
+ <span className="font-mono">{m.nodeId.split("-")[0]}</span>
196
+ <RoleBadge role={m.role} />
197
+ </span>
198
+ ))}
199
+ </div>
200
+ )}
201
+ {clusters.length === 0 && (
202
+ <p className="text-xs text-muted-foreground">
203
+ No clusters created yet. Switch to Clusters tab to create one.
204
+ </p>
205
+ )}
206
+ </div>
207
+ )}
208
+
209
+ {nodeSource === "manual" && (
210
+ <div className="mt-2 space-y-1 max-h-40 overflow-y-auto">
211
+ {meshNodes.map((node) => {
212
+ const selected = manualNodes.find(
213
+ (n) => n.nodeId === node.nodeId
214
+ );
215
+ return (
216
+ <div
217
+ key={node.nodeId}
218
+ onClick={() => toggleManualNode(node.nodeId)}
219
+ className={`flex items-center justify-between rounded px-3 py-2 text-xs cursor-pointer transition-colors ${
220
+ selected
221
+ ? "bg-accent/40 border border-accent-foreground/20"
222
+ : "bg-accent/10 hover:bg-accent/20"
223
+ }`}
224
+ >
225
+ <div className="flex items-center gap-2">
226
+ <span
227
+ className={`h-2 w-2 rounded-full ${
228
+ node.status === "online"
229
+ ? "bg-green-400"
230
+ : "bg-zinc-600"
231
+ }`}
232
+ />
233
+ <span className="font-mono">{node.nodeId}</span>
234
+ </div>
235
+ {selected && (
236
+ <div onClick={(e) => e.stopPropagation()}>
237
+ <RolePicker
238
+ value={selected.role}
239
+ onChange={(role) =>
240
+ setManualNodes((prev) =>
241
+ prev.map((n) =>
242
+ n.nodeId === node.nodeId ? { ...n, role } : n
243
+ )
244
+ )
245
+ }
246
+ />
247
+ </div>
248
+ )}
249
+ </div>
250
+ );
251
+ })}
252
+ </div>
253
+ )}
254
+ </div>
255
+
256
+ {/* Settings grid */}
257
+ <div className="grid grid-cols-2 gap-4">
258
+ {/* Mode */}
259
+ <div>
260
+ <label className="text-xs text-muted-foreground">Mode</label>
261
+ <div className="mt-1 flex gap-1">
262
+ {MODES.map((m) => (
263
+ <button
264
+ key={m}
265
+ onClick={() => setMode(m)}
266
+ className={`rounded px-2 py-1 text-xs transition-colors ${
267
+ mode === m
268
+ ? "bg-accent text-accent-foreground"
269
+ : "text-muted-foreground hover:bg-accent/50"
270
+ }`}
271
+ >
272
+ {m}
273
+ </button>
274
+ ))}
275
+ </div>
276
+ </div>
277
+
278
+ {/* Convergence */}
279
+ <div>
280
+ <label className="text-xs text-muted-foreground">Convergence</label>
281
+ <div className="mt-1 flex gap-1">
282
+ {CONVERGENCES.map((c) => (
283
+ <button
284
+ key={c}
285
+ onClick={() => setConvergenceType(c)}
286
+ className={`rounded px-2 py-1 text-xs transition-colors ${
287
+ convergenceType === c
288
+ ? "bg-accent text-accent-foreground"
289
+ : "text-muted-foreground hover:bg-accent/50"
290
+ }`}
291
+ >
292
+ {c}
293
+ </button>
294
+ ))}
295
+ </div>
296
+ {convergenceType === "majority" && (
297
+ <div className="mt-2 flex items-center gap-2">
298
+ <input
299
+ type="range"
300
+ min={50}
301
+ max={100}
302
+ value={threshold}
303
+ onChange={(e) => setThreshold(Number(e.target.value))}
304
+ className="flex-1"
305
+ />
306
+ <span className="text-xs text-muted-foreground w-8">
307
+ {threshold}%
308
+ </span>
309
+ </div>
310
+ )}
311
+ </div>
312
+
313
+ {/* Scope strategy */}
314
+ <div>
315
+ <label className="text-xs text-muted-foreground">
316
+ Scope Strategy
317
+ </label>
318
+ <div className="mt-1 flex gap-1">
319
+ {SCOPE_STRATEGIES.map((s) => (
320
+ <button
321
+ key={s}
322
+ onClick={() => setScopeStrategy(s)}
323
+ className={`rounded px-2 py-1 text-[10px] transition-colors ${
324
+ scopeStrategy === s
325
+ ? "bg-accent text-accent-foreground"
326
+ : "text-muted-foreground hover:bg-accent/50"
327
+ }`}
328
+ >
329
+ {s.replace("_", " ")}
330
+ </button>
331
+ ))}
332
+ </div>
333
+ </div>
334
+
335
+ {/* Max rounds */}
336
+ <div>
337
+ <label className="text-xs text-muted-foreground">Max Rounds</label>
338
+ <input
339
+ type="number"
340
+ min={1}
341
+ max={20}
342
+ value={maxRounds}
343
+ onChange={(e) => setMaxRounds(Number(e.target.value))}
344
+ className="mt-1 w-20 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
345
+ />
346
+ </div>
347
+
348
+ {/* Budget */}
349
+ <div>
350
+ <label className="text-xs text-muted-foreground">
351
+ Budget (minutes)
352
+ </label>
353
+ <input
354
+ type="number"
355
+ min={5}
356
+ max={120}
357
+ value={budgetMinutes}
358
+ onChange={(e) => setBudgetMinutes(Number(e.target.value))}
359
+ className="mt-1 w-20 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
360
+ />
361
+ </div>
362
+ </div>
363
+
364
+ {/* Metric */}
365
+ <div>
366
+ <label className="text-xs text-muted-foreground">
367
+ Metric (shell cmd, exits 0 = pass)
368
+ </label>
369
+ <input
370
+ type="text"
371
+ value={metric}
372
+ onChange={(e) => setMetric(e.target.value)}
373
+ placeholder='e.g. npm test -- --grep "auth"'
374
+ className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-xs font-mono outline-none focus:border-accent-foreground"
375
+ />
376
+ </div>
377
+
378
+ {/* Scope */}
379
+ <div>
380
+ <label className="text-xs text-muted-foreground">
381
+ Scope (file paths, one per line)
382
+ </label>
383
+ <textarea
384
+ value={scopeText}
385
+ onChange={(e) => setScopeText(e.target.value)}
386
+ placeholder={"lib/auth/\nsrc/components/login/"}
387
+ rows={3}
388
+ className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-xs font-mono outline-none focus:border-accent-foreground resize-none"
389
+ />
390
+ </div>
391
+
392
+ {/* Result feedback */}
393
+ {result && (
394
+ <div
395
+ className={`rounded px-3 py-2 text-xs ${
396
+ result.ok
397
+ ? "bg-green-500/10 text-green-400 border border-green-500/20"
398
+ : "bg-red-500/10 text-red-400 border border-red-500/20"
399
+ }`}
400
+ >
401
+ {result.message}
402
+ {result.ok && result.taskId && (
403
+ <a
404
+ href={`/?task=${result.taskId}`}
405
+ className="ml-2 underline underline-offset-2 decoration-dotted hover:text-green-300"
406
+ >
407
+ View on Kanban
408
+ </a>
409
+ )}
410
+ </div>
411
+ )}
412
+
413
+ {/* Submit */}
414
+ <button
415
+ onClick={handleSubmit}
416
+ disabled={!title.trim() || submitting}
417
+ className="rounded bg-accent-foreground px-4 py-2 text-sm text-accent disabled:opacity-50 transition-colors hover:opacity-90"
418
+ >
419
+ {submitting ? "Dispatching..." : "Dispatch Collab Task"}
420
+ </button>
421
+ </div>
422
+ );
423
+ }